1use crate::block_layout::block_count::validate_block_count;
64use crate::custom_family::{
65 AdditiveBlockJacobian, BlockWorkingSet, CustomFamily, ExactNewtonJointGradientEvaluation,
66 ExactNewtonJointHessianWorkspace, FamilyEvaluation, JointHessianSourcePreference,
67 ParameterBlockSpec, ParameterBlockState, PenaltyMatrix,
68};
69use crate::vector_response::{
70 MultinomialLogitLikelihood, VectorLikelihood, validate_multinomial_simplex,
71};
72use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix, SymmetricMatrix};
73use gam_math::jet_scalar::{JetScalar, OneSeed, Order2, TwoSeed};
74use gam_math::nested_dual::JetField;
75use gam_problem::HyperOperator;
76use gam_solve::pirls::dense_block_xtwx;
77use ndarray::{Array1, Array2, Array3, ArrayView2};
78use std::sync::{Arc, Mutex};
79
80#[inline]
81fn multinomial_stable_shift(eta: &[f64]) -> f64 {
82 eta.iter().copied().fold(0.0_f64, f64::max)
83}
84
85#[inline(always)]
92pub(crate) fn multinomial_logit_probabilities_into(
93 eta: &[f64],
94 probabilities: &mut [f64],
95) -> (f64, f64) {
96 assert_eq!(probabilities.len(), eta.len() + 1);
97 let shift = multinomial_stable_shift(eta);
98 let active_classes = eta.len();
99 let reference_mass = (-shift).exp();
100 let mut denominator = reference_mass;
101 for (axis, &logit) in eta.iter().enumerate() {
102 let mass = (logit - shift).exp();
103 probabilities[axis] = mass;
104 denominator += mass;
105 }
106 let inverse_denominator = denominator.recip();
107 for probability in &mut probabilities[..active_classes] {
108 *probability *= inverse_denominator;
109 }
110 probabilities[active_classes] = reference_mass * inverse_denominator;
111 (shift, denominator.ln())
112}
113
114#[derive(Clone, Copy, Debug)]
123pub struct MultinomialLogitRowProgram<'row> {
124 eta: &'row [f64],
125 response: &'row [f64],
126 weight: f64,
127}
128
129impl<'row> MultinomialLogitRowProgram<'row> {
130 pub fn new(eta: &'row [f64], response: &'row [f64], weight: f64) -> Result<Self, String> {
134 let active_classes = eta.len();
135 if active_classes == 0 {
136 return Err("MultinomialLogitRowProgram requires at least one active class".into());
137 }
138 if response.len() != active_classes + 1 {
139 return Err(format!(
140 "MultinomialLogitRowProgram response length {} must equal active classes + reference = {}",
141 response.len(),
142 active_classes + 1,
143 ));
144 }
145 if !weight.is_finite() || weight < 0.0 {
146 return Err(format!(
147 "MultinomialLogitRowProgram weight must be finite and non-negative, got {weight}"
148 ));
149 }
150 if let Some((axis, value)) = eta
151 .iter()
152 .copied()
153 .enumerate()
154 .find(|(_, value)| !value.is_finite())
155 {
156 return Err(format!(
157 "MultinomialLogitRowProgram eta[{axis}] must be finite, got {value}"
158 ));
159 }
160 if let Some((class, value)) = response
161 .iter()
162 .copied()
163 .enumerate()
164 .find(|(_, value)| !value.is_finite() || *value < 0.0)
165 {
166 return Err(format!(
167 "MultinomialLogitRowProgram response[{class}] must be finite and non-negative, got {value}"
168 ));
169 }
170 let response_mass: f64 = response.iter().sum();
171 let simplex_tolerance = 1.0e-10 * (1.0 + response.len() as f64);
172 if (response_mass - 1.0).abs() > simplex_tolerance {
173 return Err(format!(
174 "MultinomialLogitRowProgram response must sum to one, got {response_mass}"
175 ));
176 }
177 Ok(Self {
178 eta,
179 response,
180 weight,
181 })
182 }
183
184 fn require_row(row: usize) -> Result<(), String> {
185 if row != 0 {
186 return Err(format!(
187 "MultinomialLogitRowProgram holds exactly one row; got row {row}"
188 ));
189 }
190 Ok(())
191 }
192
193 #[inline]
197 fn stable_shift(&self) -> f64 {
198 multinomial_stable_shift(self.eta)
199 }
200
201 fn eval_expression<S: JetField>(&self, primaries: &[S], constant: impl Fn(f64) -> S) -> S {
212 assert_eq!(primaries.len(), self.eta.len());
213 if self.weight == 0.0 {
214 return constant(0.0);
215 }
216 let shift = self.stable_shift();
217 let mut denominator = constant((-shift).exp());
218 let mut centered_response = constant(0.0);
219 for (axis, primary) in primaries.iter().enumerate() {
220 let centered = primary.add(&constant(-shift));
221 let exponential_value = centered.value().exp();
222 let exponential = centered.compose_unary([
223 exponential_value,
224 exponential_value,
225 exponential_value,
226 exponential_value,
227 exponential_value,
228 ]);
229 denominator = denominator.add(&exponential);
230 let response = self.response[axis];
231 if response != 0.0 {
232 centered_response = centered_response.add(¢ered.scale(response));
233 }
234 }
235 let denominator_value = denominator.value();
236 let reciprocal = 1.0 / denominator_value;
237 let log_denominator = denominator.compose_unary([
238 denominator_value.ln(),
239 reciprocal,
240 -reciprocal * reciprocal,
241 2.0 * reciprocal * reciprocal * reciprocal,
242 -6.0 * reciprocal * reciprocal * reciprocal * reciprocal,
243 ]);
244 let reference_response = self.response[self.eta.len()];
245 let nll = log_denominator.sub(¢ered_response);
246 let nll = if reference_response == 0.0 {
247 nll
248 } else {
249 nll.add(&constant(reference_response * shift))
250 };
251 nll.scale(self.weight)
252 }
253
254 #[inline]
256 pub(crate) fn negative_log_likelihood(&self) -> f64 {
257 self.eval_expression(self.eta, |value| value)
258 }
259
260 #[inline(always)]
265 pub(crate) fn probabilities_into(&self, probabilities: &mut [f64]) -> (f64, f64) {
266 assert_eq!(probabilities.len(), self.response.len());
267 multinomial_logit_probabilities_into(self.eta, probabilities)
268 }
269
270 #[inline]
273 fn negative_log_likelihood_from_normalization(
274 &self,
275 shift: f64,
276 log_centered_denominator: f64,
277 ) -> f64 {
278 if self.weight == 0.0 {
279 return 0.0;
280 }
281 let mut centered_response = 0.0_f64;
282 for (axis, &response) in self.response[..self.eta.len()].iter().enumerate() {
283 if response != 0.0 {
284 centered_response += response * (self.eta[axis] - shift);
285 }
286 }
287 let reference_response = self.response[self.eta.len()];
288 let reference_term = if reference_response == 0.0 {
289 0.0
290 } else {
291 reference_response * shift
292 };
293 self.weight * (log_centered_denominator - centered_response + reference_term)
294 }
295
296 #[inline(always)]
301 pub(crate) fn value_gradient_into(
302 &self,
303 probabilities: &mut [f64],
304 gradient: &mut [f64],
305 ) -> f64 {
306 let active_classes = self.eta.len();
307 assert_eq!(gradient.len(), active_classes);
308 let (shift, log_centered_denominator) = self.probabilities_into(probabilities);
309 for axis in 0..active_classes {
310 gradient[axis] = self.weight * (probabilities[axis] - self.response[axis]);
311 }
312 self.negative_log_likelihood_from_normalization(shift, log_centered_denominator)
313 }
314
315 pub(crate) fn hessian_diagonal_into(&self, probabilities: &mut [f64], diagonal: &mut [f64]) {
318 let active_classes = self.eta.len();
319 assert_eq!(diagonal.len(), active_classes);
320 self.probabilities_into(probabilities);
321 for axis in 0..active_classes {
322 let probability = probabilities[axis];
323 diagonal[axis] = self.weight * probability * (1.0 - probability);
324 }
325 }
326
327 pub(crate) fn value_gradient_hessian_into(
340 &self,
341 probabilities: &mut [f64],
342 gradient: &mut [f64],
343 hessian: &mut [f64],
344 ) -> f64 {
345 match self.eta.len() {
346 1 => self.value_gradient_hessian_shaped::<1>(probabilities, gradient, hessian),
347 2 => self.value_gradient_hessian_shaped::<2>(probabilities, gradient, hessian),
348 3 => self.value_gradient_hessian_shaped::<3>(probabilities, gradient, hessian),
349 4 => self.value_gradient_hessian_shaped::<4>(probabilities, gradient, hessian),
350 _ => self.value_gradient_hessian_shaped::<0>(probabilities, gradient, hessian),
351 }
352 }
353
354 #[inline(always)]
359 fn value_gradient_hessian_shaped<const M_HINT: usize>(
360 &self,
361 probabilities: &mut [f64],
362 gradient: &mut [f64],
363 hessian: &mut [f64],
364 ) -> f64 {
365 let active_classes = if M_HINT == 0 {
366 self.eta.len()
367 } else {
368 assert_eq!(self.eta.len(), M_HINT);
369 M_HINT
370 };
371 assert_eq!(gradient.len(), active_classes);
372 assert_eq!(hessian.len(), active_classes * active_classes);
373 let value = self.value_gradient_into(probabilities, gradient);
374 for row in 0..active_classes {
375 let probability_row = probabilities[row];
376 for column in 0..active_classes {
377 let probability_column = probabilities[column];
378 hessian[row * active_classes + column] = self.weight
379 * if row == column {
380 probability_row * (1.0 - probability_column)
381 } else {
382 -probability_row * probability_column
383 };
384 }
385 }
386 value
387 }
388}
389
390impl<const M: usize> gam_math::jet_tower::RowProgram<M> for MultinomialLogitRowProgram<'_> {
391 fn n_rows(&self) -> usize {
392 1
393 }
394
395 fn primaries(&self, row: usize) -> Result<[f64; M], String> {
396 Self::require_row(row)?;
397 self.eta.try_into().map_err(|_| {
398 format!(
399 "MultinomialLogitRowProgram has {} active logits but RowProgram dimension is {M}",
400 self.eta.len()
401 )
402 })
403 }
404
405 fn eval<S: JetScalar<M>>(&self, row: usize, p: &[S; M]) -> Result<S, String> {
406 Self::require_row(row)?;
407 if self.eta.len() != M {
408 return Err(format!(
409 "MultinomialLogitRowProgram has {} active logits but RowProgram dimension is {M}",
410 self.eta.len()
411 ));
412 }
413 Ok(self.eval_expression(p, S::constant))
414 }
415}
416
417#[derive(Clone, Copy)]
429struct FisherDirection {
430 u: f64,
431 v: f64,
432}
433
434trait FisherPerturbation: JetScalar<0> {
435 type Channels: Copy;
436 const CONTIGUOUS_FULL: bool;
437
438 fn seed(direction: FisherDirection) -> Self;
439 fn coefficient(&self) -> f64;
440 fn from_channels(base: f64, channels: Self::Channels) -> Self;
441 fn normalized_channels(
442 probability: f64,
443 direction_u: f64,
444 mass: &Self,
445 inverse: &Self,
446 ) -> Self::Channels;
447 fn store_channels(channels: Self::Channels, weight: f64) -> Self::Channels;
448 fn fisher_weight(weight: f64) -> f64;
449 fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
450 where
451 F: Fn(usize) -> (f64, f64, Self);
452}
453
454impl FisherPerturbation for OneSeed<0> {
455 type Channels = f64;
456 const CONTIGUOUS_FULL: bool = true;
457
458 #[inline(always)]
459 fn seed(direction: FisherDirection) -> Self {
460 Self {
461 base: <Order2<0> as JetScalar<0>>::constant(0.0),
462 eps: <Order2<0> as JetScalar<0>>::constant(direction.u),
463 }
464 }
465
466 #[inline(always)]
467 fn coefficient(&self) -> f64 {
468 gam_math::nested_dual::JetField::value(&self.eps)
469 }
470
471 #[inline(always)]
472 fn from_channels(base: f64, channels: Self::Channels) -> Self {
473 Self {
474 base: <Order2<0> as JetScalar<0>>::constant(base),
475 eps: <Order2<0> as JetScalar<0>>::constant(channels),
476 }
477 }
478
479 #[inline(always)]
480 fn normalized_channels(
481 probability: f64,
482 direction_u: f64,
483 _: &Self,
484 inverse: &Self,
485 ) -> Self::Channels {
486 probability * (direction_u + gam_math::nested_dual::JetField::value(&inverse.eps))
487 }
488
489 #[inline(always)]
490 fn store_channels(channels: Self::Channels, weight: f64) -> Self::Channels {
491 channels * weight
492 }
493
494 #[inline(always)]
495 fn fisher_weight(_: f64) -> f64 {
496 1.0
497 }
498
499 #[inline(always)]
500 fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
501 where
502 F: Fn(usize) -> (f64, f64, Self),
503 {
504 let mut eps_coefficient = 0.0;
505 for a in 0..m {
506 eps_coefficient += gam_math::nested_dual::JetField::value(&perturbed_mass(a).2.eps);
507 }
508 Self {
509 base: <Order2<0> as JetScalar<0>>::constant(1.0),
510 eps: <Order2<0> as JetScalar<0>>::constant(eps_coefficient),
511 }
512 }
513}
514
515impl FisherPerturbation for TwoSeed<0> {
516 type Channels = [f64; 3];
517 const CONTIGUOUS_FULL: bool = false;
518
519 #[inline(always)]
520 fn seed(direction: FisherDirection) -> Self {
521 Self {
522 base: <Order2<0> as JetScalar<0>>::constant(0.0),
523 eps: <Order2<0> as JetScalar<0>>::constant(direction.u),
524 del: <Order2<0> as JetScalar<0>>::constant(direction.v),
525 eps_del: <Order2<0> as JetScalar<0>>::constant(0.0),
526 }
527 }
528
529 #[inline(always)]
530 fn coefficient(&self) -> f64 {
531 gam_math::nested_dual::JetField::value(&self.eps_del)
532 }
533
534 #[inline(always)]
535 fn from_channels(base: f64, channels: Self::Channels) -> Self {
536 Self {
537 base: <Order2<0> as JetScalar<0>>::constant(base),
538 eps: <Order2<0> as JetScalar<0>>::constant(channels[0]),
539 del: <Order2<0> as JetScalar<0>>::constant(channels[1]),
540 eps_del: <Order2<0> as JetScalar<0>>::constant(channels[2]),
541 }
542 }
543
544 #[inline(always)]
545 fn normalized_channels(_: f64, _: f64, mass: &Self, inverse: &Self) -> Self::Channels {
546 let normalized = gam_math::nested_dual::JetField::mul(mass, inverse);
547 [
548 gam_math::nested_dual::JetField::value(&normalized.eps),
549 gam_math::nested_dual::JetField::value(&normalized.del),
550 gam_math::nested_dual::JetField::value(&normalized.eps_del),
551 ]
552 }
553
554 #[inline(always)]
555 fn store_channels(channels: Self::Channels, _: f64) -> Self::Channels {
556 channels
557 }
558
559 #[inline(always)]
560 fn fisher_weight(weight: f64) -> f64 {
561 weight
562 }
563
564 #[inline(always)]
565 fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
566 where
567 F: Fn(usize) -> (f64, f64, Self),
568 {
569 let mut denominator = Self::constant(1.0);
570 for a in 0..m {
571 let (probability, _, mass) = perturbed_mass(a);
572 denominator = gam_math::nested_dual::JetField::add(
573 &denominator,
574 &gam_math::nested_dual::JetField::sub(&mass, &Self::constant(probability)),
575 );
576 }
577 denominator
578 }
579}
580
581#[inline(always)]
582fn fisher_entry<S: FisherPerturbation>(
583 probability_a: S,
584 probability_b: S,
585 diagonal: bool,
586 output_weight: f64,
587) -> f64 {
588 let negative_product = gam_math::nested_dual::JetField::neg(
589 &gam_math::nested_dual::JetField::mul(&probability_a, &probability_b),
590 );
591 let entry = if diagonal {
592 gam_math::nested_dual::JetField::add(&probability_a, &negative_product)
593 } else {
594 negative_product
595 };
596 gam_math::nested_dual::JetField::scale(&entry, output_weight).coefficient()
597}
598
599#[inline(always)]
600fn write_static_fisher<S: FisherPerturbation, F: Fn(usize) -> f64, const M: usize>(
601 probability: &F,
602 normalized: &[S::Channels],
603 fisher: &mut [f64],
604 output_weight: f64,
605) {
606 for a in 0..M {
607 let pa = S::from_channels(probability(a), normalized[a]);
608 fisher[a * M + a] = fisher_entry(pa, pa, true, output_weight);
609 for b in (a + 1)..M {
610 let pb = S::from_channels(probability(b), normalized[b]);
611 let coefficient = fisher_entry(pa, pb, false, output_weight);
612 fisher[a * M + b] = coefficient;
613 fisher[b * M + a] = coefficient;
614 }
615 }
616}
617
618#[derive(Clone, Copy, Eq, PartialEq)]
619enum FisherOutputSchedule {
620 SymmetricTriangle,
621 ContiguousFull,
622}
623
624const AVX2_WITHOUT_AVX512: bool = cfg!(all(target_arch = "x86_64", target_feature = "avx2"))
625 && !cfg!(all(target_arch = "x86_64", target_feature = "avx512f"));
626
627#[inline(always)]
634fn fisher_output_schedule<S: FisherPerturbation>(m: usize) -> FisherOutputSchedule {
635 if S::CONTIGUOUS_FULL && (m >= 64 || (m == 32 && AVX2_WITHOUT_AVX512)) {
636 FisherOutputSchedule::ContiguousFull
637 } else {
638 FisherOutputSchedule::SymmetricTriangle
639 }
640}
641
642#[inline(always)]
662fn softmax_fisher_perturbation<S: FisherPerturbation>(
663 m: usize,
664 weight: f64,
665 probability: impl Fn(usize) -> f64,
666 direction_u: impl Fn(usize) -> f64,
667 direction_v: impl Fn(usize) -> f64,
668 normalized: &mut [S::Channels],
669 fisher: &mut [f64],
670) {
671 assert_eq!(normalized.len(), m);
672 assert_eq!(fisher.len(), m * m);
673 let perturbed_mass = |a| {
674 let pa = probability(a);
675 let direction_u = direction_u(a);
676 let delta = S::seed(FisherDirection {
677 u: direction_u,
678 v: direction_v(a),
679 });
680 let mass = gam_math::nested_dual::JetField::scale(
681 &gam_math::nested_dual::JetField::compose_unary(&delta, [1.0; 5]),
682 pa,
683 );
684 (pa, direction_u, mass)
685 };
686 let denominator = S::denominator(m, &perturbed_mass);
687 let inverse =
688 gam_math::nested_dual::JetField::compose_unary(&denominator, [1.0, -1.0, 2.0, -6.0, 24.0]);
689 for (a, channels) in normalized.iter_mut().enumerate() {
690 let (pa, direction_u, mass) = perturbed_mass(a);
691 *channels = S::store_channels(
692 S::normalized_channels(pa, direction_u, &mass, &inverse),
693 weight,
694 );
695 }
696 let output_weight = S::fisher_weight(weight);
697 let lifted = |a| S::from_channels(probability(a), normalized[a]);
698 if m == 2 {
699 let p0 = lifted(0);
700 let p1 = lifted(1);
701 fisher[0] = fisher_entry(p0, p0, true, output_weight);
702 let off = fisher_entry(p0, p1, false, output_weight);
703 fisher[1] = off;
704 fisher[2] = off;
705 fisher[3] = fisher_entry(p1, p1, true, output_weight);
706 return;
707 }
708 if m == 3 {
709 let p0 = lifted(0);
710 let p1 = lifted(1);
711 let p2 = lifted(2);
712 fisher[0] = fisher_entry(p0, p0, true, output_weight);
713 let off01 = fisher_entry(p0, p1, false, output_weight);
714 fisher[1] = off01;
715 fisher[3] = off01;
716 let off02 = fisher_entry(p0, p2, false, output_weight);
717 fisher[2] = off02;
718 fisher[6] = off02;
719 fisher[4] = fisher_entry(p1, p1, true, output_weight);
720 let off12 = fisher_entry(p1, p2, false, output_weight);
721 fisher[5] = off12;
722 fisher[7] = off12;
723 fisher[8] = fisher_entry(p2, p2, true, output_weight);
724 return;
725 }
726 if m == 8 {
727 write_static_fisher::<S, _, 8>(&probability, normalized, fisher, output_weight);
728 return;
729 }
730 let output_schedule = fisher_output_schedule::<S>(m);
731 if m == 32 && output_schedule == FisherOutputSchedule::SymmetricTriangle {
732 write_static_fisher::<S, _, 32>(&probability, normalized, fisher, output_weight);
733 return;
734 }
735 if output_schedule == FisherOutputSchedule::ContiguousFull {
736 for a in 0..m {
737 let pa = lifted(a);
738 let row_start = a * m;
739 for b in 0..m {
740 fisher[row_start + b] = fisher_entry(pa, lifted(b), false, output_weight);
741 }
742 fisher[row_start + a] = fisher_entry(pa, pa, true, output_weight);
743 }
744 return;
745 }
746 for a in 0..m {
747 let pa = lifted(a);
748 fisher[a * m + a] = fisher_entry(pa, pa, true, output_weight);
749 for b in (a + 1)..m {
750 let coefficient = fisher_entry(pa, lifted(b), false, output_weight);
751 fisher[a * m + b] = coefficient;
752 fisher[b * m + a] = coefficient;
753 }
754 }
755}
756
757pub(crate) fn measured_penalty_rank(s: &Array2<f64>) -> Result<usize, String> {
766 let p = s.nrows();
767 if p == 0 {
768 return Ok(0);
769 }
770 use gam_linalg::faer_ndarray::FaerEigh;
771 let (eigenvalues, _) = FaerEigh::eigh(s, faer::Side::Lower)
772 .map_err(|e| format!("penalty rank eigendecomposition failed: {e}"))?;
773 let max_abs = eigenvalues
774 .iter()
775 .fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
776 let tol = 100.0 * (p as f64) * f64::EPSILON * max_abs;
777 Ok(eigenvalues.iter().filter(|&&ev| ev > tol).count())
778}
779
780pub(crate) fn centered_class_metric(m: usize, k: usize) -> Array2<f64> {
785 let inv_k = 1.0 / k as f64;
786 let mut metric = Array2::<f64>::from_elem((m, m), -inv_k);
787 for a in 0..m {
788 metric[[a, a]] += 1.0;
789 }
790 metric
791}
792
793#[derive(Clone, Debug)]
811pub struct MultinomialFamily {
812 pub y_one_hot: Array2<f64>,
819 pub weights: Array1<f64>,
821 pub total_classes: usize,
824 pub design: Arc<Array2<f64>>,
828 pub penalties: Arc<Vec<PenaltyMatrix>>,
839 likelihood: MultinomialLogitLikelihood,
842 axis_derivative_cache: Arc<Mutex<Option<AxisDerivativeCache>>>,
864 use_joint_jeffreys_term: bool,
876 initial_log_lambda: f64,
882 joint_initial_log_lambdas: Option<Vec<f64>>,
890}
891
892#[derive(Clone, Debug)]
896struct AxisDerivativeCache {
897 eta_key: EtaFingerprint,
899 derivatives: Vec<Array2<f64>>,
902}
903
904#[derive(Clone, Debug, PartialEq, Eq)]
909struct EtaFingerprint {
910 rows: usize,
911 cols: usize,
912 hash: u64,
913}
914
915impl EtaFingerprint {
916 fn of(eta: ArrayView2<'_, f64>) -> Self {
917 use std::hash::{Hash, Hasher};
918 let mut hasher = std::collections::hash_map::DefaultHasher::new();
919 let (rows, cols) = eta.dim();
920 rows.hash(&mut hasher);
921 cols.hash(&mut hasher);
922 for &v in eta.iter() {
923 v.to_bits().hash(&mut hasher);
924 }
925 EtaFingerprint {
926 rows,
927 cols,
928 hash: hasher.finish(),
929 }
930 }
931}
932
933impl MultinomialFamily {
934 pub const fn active_classes(&self) -> usize {
936 self.total_classes - 1
937 }
938
939 pub fn new(
944 y_one_hot: Array2<f64>,
945 weights: Array1<f64>,
946 total_classes: usize,
947 design: Arc<Array2<f64>>,
948 penalties: Arc<Vec<PenaltyMatrix>>,
949 ) -> Result<Self, String> {
950 if total_classes < 2 {
951 return Err(format!(
952 "MultinomialFamily requires K ≥ 2 classes (got {total_classes})"
953 ));
954 }
955 let (n, k) = y_one_hot.dim();
956 if k != total_classes {
957 return Err(format!(
958 "MultinomialFamily: y_one_hot has {k} columns but total_classes = {total_classes}"
959 ));
960 }
961 if weights.len() != n {
962 return Err(format!(
963 "MultinomialFamily: weights length {} != N = {n}",
964 weights.len()
965 ));
966 }
967 for (i, &v) in weights.iter().enumerate() {
968 if !(v.is_finite() && v >= 0.0) {
969 return Err(format!(
970 "MultinomialFamily: weights[{i}] must be finite and non-negative (got {v})"
971 ));
972 }
973 }
974 if design.nrows() != n {
975 return Err(format!(
976 "MultinomialFamily: design has {} rows, expected {n}",
977 design.nrows()
978 ));
979 }
980 let p = design.ncols();
981 for (t, penalty) in penalties.iter().enumerate() {
982 if penalty.shape() != (p, p) {
983 return Err(format!(
984 "MultinomialFamily: penalties[{t}] shape {:?} != (P, P) = ({p}, {p})",
985 penalty.shape()
986 ));
987 }
988 for ((i, j), &v) in penalty.to_dense().indexed_iter() {
989 if !v.is_finite() {
990 return Err(format!(
991 "MultinomialFamily: penalties[{t}][{i},{j}] must be finite (got {v})"
992 ));
993 }
994 }
995 }
996 validate_multinomial_simplex(y_one_hot.view(), "MultinomialFamily")
997 .map_err(|e| e.to_string())?;
998 for ((i, j), &v) in design.indexed_iter() {
999 if !v.is_finite() {
1000 return Err(format!(
1001 "MultinomialFamily: design[{i},{j}] must be finite (got {v})"
1002 ));
1003 }
1004 }
1005
1006 let likelihood = MultinomialLogitLikelihood::with_classes(total_classes)
1009 .map_err(|e| format!("MultinomialFamily: {e}"))?
1010 .with_row_weights(weights.clone())
1011 .map_err(|e| format!("MultinomialFamily: {e}"))?;
1012
1013 Ok(Self {
1014 y_one_hot,
1015 weights,
1016 total_classes,
1017 design,
1018 penalties,
1019 likelihood,
1020 axis_derivative_cache: Arc::new(Mutex::new(None)),
1021 use_joint_jeffreys_term: true,
1022 initial_log_lambda: 0.0,
1023 joint_initial_log_lambdas: None,
1024 })
1025 }
1026
1027 pub fn with_joint_jeffreys_term(mut self, enabled: bool) -> Self {
1030 self.use_joint_jeffreys_term = enabled;
1031 self
1032 }
1033
1034 pub fn with_initial_log_lambda(mut self, log_lambda: f64) -> Self {
1039 self.initial_log_lambda = log_lambda;
1040 self
1041 }
1042
1043 pub fn with_joint_initial_log_lambdas(mut self, seeds: Vec<f64>) -> Self {
1050 self.joint_initial_log_lambdas = Some(seeds);
1051 self
1052 }
1053
1054 fn joint_seed(&self, spec_index: usize) -> f64 {
1057 self.joint_initial_log_lambdas
1058 .as_ref()
1059 .and_then(|seeds| seeds.get(spec_index))
1060 .copied()
1061 .unwrap_or(self.initial_log_lambda)
1062 }
1063
1064 fn validate_joint_seed_len(&self, expected: usize, carrier: &str) -> Result<(), String> {
1067 match self.joint_initial_log_lambdas.as_ref() {
1068 Some(seeds) if seeds.len() != expected => Err(format!(
1069 "multinomial {carrier} carrier: joint_initial_log_lambdas has {} entries, \
1070 expected {expected} (one per joint spec, term-major)",
1071 seeds.len()
1072 )),
1073 _ => Ok(()),
1074 }
1075 }
1076
1077 pub fn build_block_specs(&self) -> Vec<ParameterBlockSpec> {
1094 let m = self.active_classes();
1095 (0..m)
1096 .map(|a| {
1097 let priority = 100u8.saturating_add(u8::try_from(m - a).unwrap_or(u8::MAX));
1098 let mut spec = ParameterBlockSpec {
1120 name: format!("class_{a}"),
1121 design: DesignMatrix::Dense(DenseDesignMatrix::from(self.design.clone())),
1122 offset: Array1::<f64>::zeros(self.design.nrows()),
1123 penalties: Vec::new(),
1124 nullspace_dims: Vec::new(),
1125 initial_log_lambdas: Array1::<f64>::zeros(0),
1126 initial_beta: None,
1127 gauge_priority: priority,
1128 jacobian_callback: None,
1129 stacked_design: None,
1130 stacked_offset: None,
1131 };
1132 spec.jacobian_callback = Some(Arc::new(AdditiveBlockJacobian {
1133 design: (*self.design).clone(),
1134 own_output: a,
1135 n_family_outputs: m,
1136 }));
1137 spec
1138 })
1139 .collect()
1140 }
1141
1142 pub fn beta_flat_dim(&self) -> usize {
1144 self.active_classes() * self.design.ncols()
1145 }
1146
1147 pub fn centered_joint_penalty_specs(
1167 &self,
1168 ) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
1169 let m = self.active_classes();
1170 let k = self.total_classes;
1171 let p = self.design.ncols();
1172 let metric = centered_class_metric(m, k);
1173 let raw_total = m * p;
1174 self.validate_joint_seed_len(self.penalties.len(), "shared centered")?;
1175 self.penalties
1176 .iter()
1177 .enumerate()
1178 .map(|(t, pen)| {
1179 let s_t = pen.to_dense();
1180 let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
1181 for a in 0..m {
1182 for b in 0..m {
1183 let scale = metric[[a, b]];
1184 for i in 0..p {
1185 for j in 0..p {
1186 matrix[[a * p + i, b * p + j]] = scale * s_t[[i, j]];
1187 }
1188 }
1189 }
1190 }
1191 let rank_s = measured_penalty_rank(&s_t)
1196 .map_err(|e| format!("multinomial centered penalty term {t}: {e}"))?;
1197 Ok(gam_problem::JointPenaltySpec {
1198 label: Some(format!("multinomial_term_{t}")),
1199 matrix,
1200 initial_log_lambda: self.joint_seed(t),
1201 nullspace_dim: raw_total - m * rank_s,
1202 })
1203 })
1204 .collect()
1205 }
1206
1207 pub fn equivariant_class_penalty_specs(
1238 &self,
1239 ) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
1240 let m = self.active_classes();
1241 let k = self.total_classes;
1242 let p = self.design.ncols();
1243 if k <= 2 {
1244 return self.centered_joint_penalty_specs();
1245 }
1246 let raw_total = m * p;
1247 self.validate_joint_seed_len(self.penalties.len() * k, "equivariant per-class")?;
1248 let mut specs = Vec::with_capacity(self.penalties.len() * k);
1249 for (t, pen) in self.penalties.iter().enumerate() {
1250 let s_t = pen.to_dense();
1251 let rank_s = measured_penalty_rank(&s_t)
1257 .map_err(|e| format!("multinomial equivariant penalty term {t}: {e}"))?;
1258 let nullspace_dim = raw_total - rank_s;
1259 for c in 0..k {
1260 let row: Vec<f64> = (0..m)
1262 .map(|b| {
1263 let indicator = if c == b { 1.0 } else { 0.0 };
1264 indicator - 1.0 / (k as f64)
1265 })
1266 .collect();
1267 let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
1268 for a in 0..m {
1269 for b in 0..m {
1270 let scale = row[a] * row[b];
1271 if scale == 0.0 {
1272 continue;
1273 }
1274 for i in 0..p {
1275 for j in 0..p {
1276 matrix[[a * p + i, b * p + j]] = scale * s_t[[i, j]];
1277 }
1278 }
1279 }
1280 }
1281 specs.push(gam_problem::JointPenaltySpec {
1282 label: Some(format!("multinomial_term_{t}_class_{c}")),
1283 matrix,
1284 initial_log_lambda: self.joint_seed(t * k + c),
1285 nullspace_dim,
1286 });
1287 }
1288 }
1289 Ok(specs)
1290 }
1291
1292 fn specs_match_workspace_shape(&self, specs: &[ParameterBlockSpec]) -> bool {
1293 let n = self.weights.len();
1294 let p = self.design.ncols();
1295 specs.len() == self.active_classes()
1296 && specs.iter().all(|spec| {
1297 spec.design.nrows() == n
1298 && spec.design.ncols() == p
1299 && spec.offset.len() == n
1300 && spec.stacked_design.is_none()
1301 && spec.stacked_offset.is_none()
1302 && spec.initial_log_lambdas.len() == self.penalties.len()
1303 && spec.penalties.len() == self.penalties.len()
1304 })
1305 }
1306
1307 fn collect_eta_matrix(
1310 &self,
1311 block_states: &[ParameterBlockState],
1312 ) -> Result<Array2<f64>, String> {
1313 let m = self.active_classes();
1314 validate_block_count::<String>("MultinomialFamily", m, block_states.len())?;
1315 let n = self.weights.len();
1316 let mut eta = Array2::<f64>::zeros((n, m));
1317 for (a, state) in block_states.iter().enumerate() {
1318 if state.eta.len() != n {
1319 return Err(format!(
1320 "MultinomialFamily block {a} eta length {} != N = {n}",
1321 state.eta.len()
1322 ));
1323 }
1324 for row in 0..n {
1325 eta[[row, a]] = state.eta[row];
1326 }
1327 }
1328 Ok(eta)
1329 }
1330
1331 fn evaluate_row_kernels(
1336 &self,
1337 eta: ArrayView2<'_, f64>,
1338 ) -> Result<(f64, Array3<f64>, Array2<f64>), String> {
1339 let (log_lik, grad_eta_logl, fisher) = self
1340 .likelihood
1341 .value_gradient_hessian(eta, self.y_one_hot.view())
1342 .map_err(|error| error.to_string())?;
1343 Ok((log_lik, fisher, grad_eta_logl))
1344 }
1345
1346 fn assemble_block_diagonal_working_sets(
1354 &self,
1355 fisher: &Array3<f64>,
1356 grad_eta_logl: &Array2<f64>,
1357 ) -> Result<Vec<BlockWorkingSet>, String> {
1358 let n = self.weights.len();
1359 let p = self.design.ncols();
1360 let m = self.active_classes();
1361 let design_view = self.design.view();
1362
1363 let mut sets = Vec::with_capacity(m);
1364 for a in 0..m {
1365 let mut grad = Array1::<f64>::zeros(p);
1367 for i in 0..p {
1368 let mut acc = 0.0_f64;
1369 for row in 0..n {
1370 acc += design_view[[row, i]] * (-grad_eta_logl[[row, a]]);
1371 }
1372 grad[i] = acc;
1373 }
1374 let mut hess = Array2::<f64>::zeros((p, p));
1376 for row in 0..n {
1377 let w_aa = fisher[[row, a, a]];
1378 if w_aa == 0.0 {
1379 continue;
1380 }
1381 for i in 0..p {
1382 let xi = design_view[[row, i]];
1383 if xi == 0.0 {
1384 continue;
1385 }
1386 let scaled = w_aa * xi;
1387 for j in 0..p {
1388 hess[[i, j]] += scaled * design_view[[row, j]];
1389 }
1390 }
1391 }
1392 for i in 0..p {
1394 for j in (i + 1)..p {
1395 let avg = 0.5 * (hess[[i, j]] + hess[[j, i]]);
1396 hess[[i, j]] = avg;
1397 hess[[j, i]] = avg;
1398 }
1399 }
1400 sets.push(BlockWorkingSet::ExactNewton {
1401 gradient: grad,
1402 hessian: SymmetricMatrix::Dense(hess),
1403 });
1404 }
1405 Ok(sets)
1406 }
1407
1408 fn assemble_joint_hessian(&self, fisher: &Array3<f64>) -> Result<Array2<f64>, String> {
1412 dense_block_xtwx(self.design.view(), fisher.view(), None)
1413 .map_err(|e| format!("MultinomialFamily joint Hessian assembly: {e}"))
1414 }
1415
1416 fn assemble_joint_gradient(&self, grad_eta_logl: &Array2<f64>) -> Array1<f64> {
1420 let n = self.weights.len();
1421 let p = self.design.ncols();
1422 let m = self.active_classes();
1423 let design_view = self.design.view();
1424 let mut out = Array1::<f64>::zeros(m * p);
1425 for a in 0..m {
1426 for i in 0..p {
1427 let mut acc = 0.0_f64;
1428 for row in 0..n {
1429 acc += design_view[[row, i]] * grad_eta_logl[[row, a]];
1430 }
1431 out[a * p + i] = acc;
1432 }
1433 }
1434 out
1435 }
1436
1437 fn joint_loglik_and_gradient_from_probs(
1450 &self,
1451 eta: ArrayView2<'_, f64>,
1452 probs_full: ArrayView2<'_, f64>,
1453 ) -> Result<(f64, Array1<f64>), String> {
1454 let n = self.weights.len();
1455 let p = self.design.ncols();
1456 let m = self.active_classes();
1457 let k = self.total_classes;
1458 let design_view = self.design.view();
1459 assert_eq!(eta.dim(), (n, m));
1460 assert_eq!(probs_full.dim(), (n, k));
1461 let mut log_lik = 0.0_f64;
1462 let mut eta_row = vec![0.0_f64; m];
1463 let mut response_row = vec![0.0_f64; k];
1464 for row in 0..n {
1465 let w = self.weights[row];
1466 if w == 0.0 {
1467 continue;
1468 }
1469 for axis in 0..m {
1470 eta_row[axis] = eta[[row, axis]];
1471 }
1472 for class in 0..k {
1473 response_row[class] = self.y_one_hot[[row, class]];
1474 }
1475 let program = MultinomialLogitRowProgram::new(&eta_row, &response_row, w)
1476 .map_err(|error| format!("invalid frozen multinomial row {row}: {error}"))?;
1477 log_lik -= program.negative_log_likelihood();
1478 }
1479 let mut grad = Array1::<f64>::zeros(m * p);
1480 for a in 0..m {
1481 for i in 0..p {
1482 let mut acc = 0.0_f64;
1483 for row in 0..n {
1484 let resid =
1485 self.weights[row] * (self.y_one_hot[[row, a]] - probs_full[[row, a]]);
1486 acc += design_view[[row, i]] * resid;
1487 }
1488 grad[a * p + i] = acc;
1489 }
1490 }
1491 Ok((log_lik, grad))
1492 }
1493
1494 fn d_eta_from_d_beta(&self, d_beta_flat: &Array1<f64>) -> Result<Array2<f64>, String> {
1498 let p = self.design.ncols();
1499 let m = self.active_classes();
1500 let n = self.design.nrows();
1501 if d_beta_flat.len() != m * p {
1502 return Err(format!(
1503 "MultinomialFamily direction length {} != (K-1)·P = {}",
1504 d_beta_flat.len(),
1505 m * p
1506 ));
1507 }
1508 let mut d_eta = Array2::<f64>::zeros((n, m));
1509 let design_view = self.design.view();
1510 for a in 0..m {
1511 for row in 0..n {
1512 let mut acc = 0.0_f64;
1513 for i in 0..p {
1514 acc += design_view[[row, i]] * d_beta_flat[a * p + i];
1515 }
1516 d_eta[[row, a]] = acc;
1517 }
1518 }
1519 Ok(d_eta)
1520 }
1521
1522 fn row_probabilities(&self, eta: ArrayView2<'_, f64>) -> Array2<f64> {
1525 self.likelihood.probabilities(eta)
1526 }
1527
1528 fn hessian_matvec_into_with_probs(
1552 &self,
1553 probs_full: ArrayView2<'_, f64>,
1554 v: &Array1<f64>,
1555 out: &mut Array1<f64>,
1556 ) -> Result<(), String> {
1557 let p = self.design.ncols();
1558 let m = self.active_classes();
1559 let n = self.weights.len();
1560 let total = m * p;
1561 if v.len() != total {
1562 return Err(format!(
1563 "MultinomialHessianWorkspace::hessian_matvec: v len {} != (K-1)·P = {total}",
1564 v.len()
1565 ));
1566 }
1567 if out.len() != total {
1568 return Err(format!(
1569 "MultinomialHessianWorkspace::hessian_matvec: out len {} != (K-1)·P = {total}",
1570 out.len()
1571 ));
1572 }
1573 out.fill(0.0);
1574 let design = self.design.view();
1575 let mut xv = vec![0.0_f64; m];
1576 for row in 0..n {
1577 let w = self.weights[row];
1578 if w == 0.0 {
1579 continue;
1580 }
1581 let mut s = 0.0_f64;
1584 for b in 0..m {
1585 let mut acc = 0.0_f64;
1586 for j in 0..p {
1587 acc += design[[row, j]] * v[b * p + j];
1588 }
1589 xv[b] = acc;
1590 s += probs_full[[row, b]] * acc;
1591 }
1592 for a in 0..m {
1594 let r = w * probs_full[[row, a]] * (xv[a] - s);
1595 if r == 0.0 {
1596 continue;
1597 }
1598 let base = a * p;
1599 for i in 0..p {
1600 out[base + i] += design[[row, i]] * r;
1601 }
1602 }
1603 }
1604 Ok(())
1605 }
1606
1607 fn hessian_diagonal_with_probs(&self, probs_full: ArrayView2<'_, f64>) -> Array1<f64> {
1624 let p = self.design.ncols();
1625 let m = self.active_classes();
1626 let n = self.weights.len();
1627 let dim = m * p;
1628 let design = self.design.view();
1629 gam_problem::outer_subsample::RowSet::All.par_reduce_fold(
1630 n,
1631 || Array1::<f64>::zeros(dim),
1632 |mut acc, row, _row_weight| {
1633 let w = self.weights[row];
1634 if w == 0.0 {
1635 return acc;
1636 }
1637 for a in 0..m {
1638 let pa = probs_full[[row, a]];
1639 let waa = w * pa * (1.0 - pa);
1640 if waa == 0.0 {
1641 continue;
1642 }
1643 let base = a * p;
1644 for i in 0..p {
1645 let xi = design[[row, i]];
1646 acc[base + i] += waa * xi * xi;
1647 }
1648 }
1649 acc
1650 },
1651 |mut a, b| {
1652 a += &b;
1653 a
1654 },
1655 )
1656 }
1657
1658 fn directional_fisher_jet(
1681 &self,
1682 eta: ArrayView2<'_, f64>,
1683 d_beta_flat: &Array1<f64>,
1684 ) -> Result<Array3<f64>, String> {
1685 let p = self.design.ncols();
1686 let m = self.active_classes();
1687 if d_beta_flat.len() != m * p {
1688 return Err(format!(
1689 "MultinomialFamily direction length {} != (K-1)·P = {}",
1690 d_beta_flat.len(),
1691 m * p
1692 ));
1693 }
1694 let probs_full = self.row_probabilities(eta);
1695 Ok(self.directional_fisher_jet_rows(probs_full.view(), d_beta_flat))
1696 }
1697
1698 fn directional_fisher_jet_rows(
1710 &self,
1711 probs_full: ArrayView2<'_, f64>,
1712 direction: &Array1<f64>,
1713 ) -> Array3<f64> {
1714 let n = self.weights.len();
1715 let p = self.design.ncols();
1716 let m = self.active_classes();
1717 let design = self.design.view();
1718 let mut out = Array3::<f64>::zeros((n, m, m));
1719 let mut d_eta = vec![0.0_f64; m];
1720 let mut normalized = vec![0.0; m];
1721 let out_flat = out
1722 .as_slice_mut()
1723 .expect("owned Fisher jet must be contiguous");
1724 for row in 0..n {
1725 let w = self.weights[row];
1726 if w == 0.0 {
1727 continue;
1728 }
1729 for a in 0..m {
1730 let base = a * p;
1731 let mut eta_dir = 0.0_f64;
1732 for i in 0..p {
1733 eta_dir += design[[row, i]] * direction[base + i];
1734 }
1735 d_eta[a] = eta_dir;
1736 }
1737 let row_start = row * m * m;
1738 softmax_fisher_perturbation::<OneSeed<0>>(
1739 m,
1740 w,
1741 |a| probs_full[[row, a]],
1742 |a| d_eta[a],
1743 |_| 0.0,
1744 &mut normalized,
1745 &mut out_flat[row_start..row_start + m * m],
1746 );
1747 }
1748 out
1749 }
1750
1751 fn second_directional_fisher_jet_rows(
1758 &self,
1759 probs_full: ArrayView2<'_, f64>,
1760 u: &Array1<f64>,
1761 v: &Array1<f64>,
1762 ) -> Array3<f64> {
1763 let n = self.weights.len();
1764 let p = self.design.ncols();
1765 let m = self.active_classes();
1766 let design = self.design.view();
1767 let mut out = Array3::<f64>::zeros((n, m, m));
1768 let mut d_eta_u = vec![0.0_f64; m];
1769 let mut d_eta_v = vec![0.0_f64; m];
1770 let mut normalized = vec![[0.0; 3]; m];
1771 let out_flat = out
1772 .as_slice_mut()
1773 .expect("owned Fisher jet must be contiguous");
1774 for row in 0..n {
1775 let w = self.weights[row];
1776 if w == 0.0 {
1777 continue;
1778 }
1779 for a in 0..m {
1780 let base = a * p;
1781 let mut eta_u = 0.0_f64;
1782 let mut eta_v = 0.0_f64;
1783 for i in 0..p {
1784 let x = design[[row, i]];
1785 eta_u += x * u[base + i];
1786 eta_v += x * v[base + i];
1787 }
1788 d_eta_u[a] = eta_u;
1789 d_eta_v[a] = eta_v;
1790 }
1791 let row_start = row * m * m;
1792 softmax_fisher_perturbation::<TwoSeed<0>>(
1793 m,
1794 w,
1795 |a| probs_full[[row, a]],
1796 |a| d_eta_u[a],
1797 |a| d_eta_v[a],
1798 &mut normalized,
1799 &mut out_flat[row_start..row_start + m * m],
1800 );
1801 }
1802 out
1803 }
1804
1805 fn directional_hyper_operator(
1811 &self,
1812 probs_full: ArrayView2<'_, f64>,
1813 direction: &Array1<f64>,
1814 ) -> Result<MultinomialDirectionalHyperOperator, String> {
1815 let dim = self.beta_flat_dim();
1816 if direction.len() != dim {
1817 return Err(format!(
1818 "MultinomialFamily matrix-free direction length {} != (K-1)·P = {dim}",
1819 direction.len()
1820 ));
1821 }
1822 Ok(MultinomialDirectionalHyperOperator {
1823 design: Arc::clone(&self.design),
1824 jet: self.directional_fisher_jet_rows(probs_full, direction),
1825 m: self.active_classes(),
1826 p: self.design.ncols(),
1827 })
1828 }
1829
1830 fn second_directional_hyper_operator(
1833 &self,
1834 probs_full: ArrayView2<'_, f64>,
1835 u: &Array1<f64>,
1836 v: &Array1<f64>,
1837 ) -> Result<MultinomialDirectionalHyperOperator, String> {
1838 let dim = self.beta_flat_dim();
1839 if u.len() != dim || v.len() != dim {
1840 return Err(format!(
1841 "MultinomialFamily matrix-free second-directional pair lengths {} and {} != (K-1)·P = {dim}",
1842 u.len(),
1843 v.len()
1844 ));
1845 }
1846 Ok(MultinomialDirectionalHyperOperator {
1847 design: Arc::clone(&self.design),
1848 jet: self.second_directional_fisher_jet_rows(probs_full, u, v),
1849 m: self.active_classes(),
1850 p: self.design.ncols(),
1851 })
1852 }
1853
1854 fn second_directional_fisher_jet(
1870 &self,
1871 eta: ArrayView2<'_, f64>,
1872 d_beta_u: &Array1<f64>,
1873 d_beta_v: &Array1<f64>,
1874 ) -> Result<Array3<f64>, String> {
1875 let p = self.design.ncols();
1876 let m = self.active_classes();
1877 let dim = m * p;
1878 if d_beta_u.len() != dim || d_beta_v.len() != dim {
1879 return Err(format!(
1880 "MultinomialFamily second-directional pair lengths {} and {} != (K-1)·P = {dim}",
1881 d_beta_u.len(),
1882 d_beta_v.len()
1883 ));
1884 }
1885 let probs_full = self.row_probabilities(eta);
1886 Ok(self.second_directional_fisher_jet_rows(probs_full.view(), d_beta_u, d_beta_v))
1887 }
1888
1889 fn assemble_all_axis_directional_derivatives(
1918 &self,
1919 eta: ArrayView2<'_, f64>,
1920 ) -> Vec<Array2<f64>> {
1921 use rayon::iter::{IntoParallelIterator, ParallelIterator};
1922 let n = self.weights.len();
1923 let p = self.design.ncols();
1924 let m = self.active_classes();
1925 let dim = m * p;
1926 let n_axes = m * p;
1927 let probs_full = self.row_probabilities(eta);
1928 let design = self.design.view();
1929 (0..n_axes)
1942 .into_par_iter()
1943 .map(|axis| {
1944 let a0 = axis / p;
1945 let i0 = axis % p;
1946 let mut mat = vec![0.0_f64; dim * dim];
1947 let mut normalized = vec![0.0; m];
1948 let mut jhat = vec![0.0_f64; m * m];
1949 for row in 0..n {
1950 let w = self.weights[row];
1951 if w == 0.0 {
1952 continue;
1953 }
1954 let xi0 = design[[row, i0]];
1955 if xi0 == 0.0 {
1956 continue;
1957 }
1958 softmax_fisher_perturbation::<OneSeed<0>>(
1959 m,
1960 w,
1961 |c| probs_full[[row, c]],
1962 |c| if c == a0 { 1.0 } else { 0.0 },
1963 |_| 0.0,
1964 &mut normalized,
1965 &mut jhat,
1966 );
1967 for c in 0..m {
1971 let row_c = c * p;
1972 for d in 0..m {
1973 let jcd = jhat[c * m + d];
1974 if jcd == 0.0 {
1975 continue;
1976 }
1977 let wcd = xi0 * jcd;
1978 let col_d = d * p;
1979 for i in 0..p {
1980 let xi = design[[row, i]];
1981 if xi == 0.0 {
1982 continue;
1983 }
1984 let scaled = wcd * xi;
1985 let out_row = (row_c + i) * dim;
1986 for j in 0..p {
1987 mat[out_row + col_d + j] += scaled * design[[row, j]];
1988 }
1989 }
1990 }
1991 }
1992 }
1993 let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
1994 .expect("axis derivative buffer is dim·dim");
1995 for i in 0..dim {
1999 for j in (i + 1)..dim {
2000 let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
2001 mat[[i, j]] = avg;
2002 mat[[j, i]] = avg;
2003 }
2004 }
2005 mat
2006 })
2007 .collect()
2008 }
2009
2010 fn assemble_all_axis_second_directional_derivatives(
2044 &self,
2045 eta: ArrayView2<'_, f64>,
2046 d_beta_u: &Array1<f64>,
2047 ) -> Result<Vec<Array2<f64>>, String> {
2048 use rayon::iter::{IntoParallelIterator, ParallelIterator};
2049 let n = self.weights.len();
2050 let p = self.design.ncols();
2051 let m = self.active_classes();
2052 let dim = m * p;
2053 let n_axes = m * p;
2054 let probs_full = self.row_probabilities(eta);
2055 let d_eta_u = self.d_eta_from_d_beta(d_beta_u)?;
2056 let design = self.design.view();
2057 let out: Vec<Array2<f64>> = (0..n_axes)
2064 .into_par_iter()
2065 .map(|axis| {
2066 let a0 = axis / p;
2067 let i0 = axis % p;
2068 let mut mat = vec![0.0_f64; dim * dim];
2069 let mut normalized = vec![[0.0; 3]; m];
2070 let mut jhat = vec![0.0_f64; m * m];
2071 for row in 0..n {
2072 let w = self.weights[row];
2073 if w == 0.0 {
2074 continue;
2075 }
2076 let xi0 = design[[row, i0]];
2077 if xi0 == 0.0 {
2078 continue;
2079 }
2080 softmax_fisher_perturbation::<TwoSeed<0>>(
2081 m,
2082 w,
2083 |c| probs_full[[row, c]],
2084 |c| d_eta_u[[row, c]],
2085 |c| if c == a0 { 1.0 } else { 0.0 },
2086 &mut normalized,
2087 &mut jhat,
2088 );
2089 for c in 0..m {
2092 let row_c = c * p;
2093 for d in 0..m {
2094 let jcd = jhat[c * m + d];
2095 if jcd == 0.0 {
2096 continue;
2097 }
2098 let wcd = xi0 * jcd;
2099 let col_d = d * p;
2100 for i in 0..p {
2101 let xi = design[[row, i]];
2102 if xi == 0.0 {
2103 continue;
2104 }
2105 let scaled = wcd * xi;
2106 let out_row = (row_c + i) * dim;
2107 for j in 0..p {
2108 mat[out_row + col_d + j] += scaled * design[[row, j]];
2109 }
2110 }
2111 }
2112 }
2113 }
2114 let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
2115 .expect("axis second-derivative buffer is dim·dim");
2116 for i in 0..dim {
2117 for j in (i + 1)..dim {
2118 let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
2119 mat[[i, j]] = avg;
2120 mat[[j, i]] = avg;
2121 }
2122 }
2123 mat
2124 })
2125 .collect();
2126 Ok(out)
2127 }
2128
2129 fn canonical_axis_index(&self, d_beta_flat: &Array1<f64>) -> Option<usize> {
2132 let mut axis: Option<usize> = None;
2133 for (k, &v) in d_beta_flat.iter().enumerate() {
2134 if v == 0.0 {
2135 continue;
2136 }
2137 if v != 1.0 || axis.is_some() {
2138 return None;
2139 }
2140 axis = Some(k);
2141 }
2142 axis
2143 }
2144
2145 fn cached_axis_directional_derivative(
2152 &self,
2153 eta: ArrayView2<'_, f64>,
2154 axis: usize,
2155 ) -> Array2<f64> {
2156 let key = EtaFingerprint::of(eta);
2157 {
2158 let guard = self
2159 .axis_derivative_cache
2160 .lock()
2161 .expect("axis derivative cache mutex poisoned");
2162 if let Some(cache) = guard.as_ref()
2163 && cache.eta_key == key
2164 {
2165 return cache.derivatives[axis].clone();
2166 }
2167 }
2168 let derivatives = self.assemble_all_axis_directional_derivatives(eta);
2173 let result = derivatives[axis].clone();
2174 let mut guard = self
2175 .axis_derivative_cache
2176 .lock()
2177 .expect("axis derivative cache mutex poisoned");
2178 *guard = Some(AxisDerivativeCache {
2179 eta_key: key,
2180 derivatives,
2181 });
2182 result
2183 }
2184}
2185
2186impl CustomFamily for MultinomialFamily {
2187 fn joint_jeffreys_term_required(&self) -> bool {
2188 self.use_joint_jeffreys_term
2189 }
2190
2191 fn joint_penalty_specs(&self) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
2192 self.equivariant_class_penalty_specs()
2203 }
2204
2205 fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
2206 true
2208 }
2209
2210 fn has_explicit_joint_hessian(&self) -> bool {
2211 true
2212 }
2213
2214 fn requires_joint_outer_hyper_path(&self) -> bool {
2215 true
2218 }
2219
2220 fn levenberg_on_ill_conditioning(&self) -> bool {
2221 true
2247 }
2248
2249 fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2250 self.specs_match_workspace_shape(specs)
2251 }
2252
2253 fn inner_joint_workspace_gradient_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2254 self.specs_match_workspace_shape(specs)
2255 }
2256
2257 fn inner_joint_workspace_log_likelihood_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2258 self.specs_match_workspace_shape(specs)
2259 }
2260
2261 fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
2262 crate::custom_family::joint_coupled_coefficient_hessian_cost(
2265 self.weights.len() as u64,
2266 specs,
2267 )
2268 }
2269
2270 fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
2271 let eta = self.collect_eta_matrix(block_states)?;
2272 let (log_lik, fisher, grad_eta_logl) = self.evaluate_row_kernels(eta.view())?;
2273 let working_sets = self.assemble_block_diagonal_working_sets(&fisher, &grad_eta_logl)?;
2274 Ok(FamilyEvaluation {
2275 log_likelihood: log_lik,
2276 blockworking_sets: working_sets,
2277 })
2278 }
2279
2280 fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
2281 let eta = self.collect_eta_matrix(block_states)?;
2282 self.likelihood
2283 .log_lik(eta.view(), self.y_one_hot.view())
2284 .map_err(|error| error.to_string())
2285 }
2286
2287 fn exact_newton_joint_hessian(
2288 &self,
2289 block_states: &[ParameterBlockState],
2290 ) -> Result<Option<Array2<f64>>, String> {
2291 let eta = self.collect_eta_matrix(block_states)?;
2292 let (_, fisher, _) = self.evaluate_row_kernels(eta.view())?;
2293 let hessian = self.assemble_joint_hessian(&fisher)?;
2294 Ok(Some(hessian))
2295 }
2296
2297 fn exact_newton_joint_gradient_evaluation(
2298 &self,
2299 block_states: &[ParameterBlockState],
2300 _: &[ParameterBlockSpec],
2301 ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
2302 let eta = self.collect_eta_matrix(block_states)?;
2303 let (log_lik, grad_eta_logl) = self
2304 .likelihood
2305 .value_gradient(eta.view(), self.y_one_hot.view())
2306 .map_err(|error| error.to_string())?;
2307 let gradient = self.assemble_joint_gradient(&grad_eta_logl);
2308 Ok(Some(ExactNewtonJointGradientEvaluation {
2309 log_likelihood: log_lik,
2310 gradient,
2311 }))
2312 }
2313
2314 fn exact_newton_joint_hessian_workspace(
2315 &self,
2316 block_states: &[ParameterBlockState],
2317 _: &[ParameterBlockSpec],
2318 ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
2319 let eta = self.collect_eta_matrix(block_states)?;
2325 let probs = self.row_probabilities(eta.view());
2326 Ok(Some(Arc::new(MultinomialHessianWorkspace {
2327 family: self.clone(),
2328 block_states: block_states.to_vec(),
2329 eta,
2330 probs,
2331 })))
2332 }
2333
2334 fn exact_newton_joint_hessian_directional_derivative(
2335 &self,
2336 block_states: &[ParameterBlockState],
2337 d_beta_flat: &Array1<f64>,
2338 ) -> Result<Option<Array2<f64>>, String> {
2339 let eta = self.collect_eta_matrix(block_states)?;
2340 if d_beta_flat.len() != self.beta_flat_dim() {
2341 return Err(format!(
2342 "MultinomialFamily direction length {} != (K-1)·P = {}",
2343 d_beta_flat.len(),
2344 self.beta_flat_dim()
2345 ));
2346 }
2347 if let Some(axis) = self.canonical_axis_index(d_beta_flat) {
2354 return Ok(Some(
2355 self.cached_axis_directional_derivative(eta.view(), axis),
2356 ));
2357 }
2358 let dh_fisher = self.directional_fisher_jet(eta.view(), d_beta_flat)?;
2361 let dh = dense_block_xtwx(self.design.view(), dh_fisher.view(), None)
2362 .map_err(|e| format!("MultinomialFamily directional H assembly: {e}"))?;
2363 Ok(Some(dh))
2364 }
2365
2366 fn joint_jeffreys_information_directional_derivative_all_axes_with_specs(
2367 &self,
2368 block_states: &[ParameterBlockState],
2369 specs: &[ParameterBlockSpec],
2370 ) -> Result<Option<Vec<Array2<f64>>>, String> {
2371 let eta = self.collect_eta_matrix(block_states)?;
2387 let axes = self.assemble_all_axis_directional_derivatives(eta.view());
2388 let p: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
2395 if axes.len() != p {
2396 log::warn!(
2397 "multinomial all-axes Jeffreys derivative produced {} axes but the block specs \
2398 describe p={p} joint coefficients (canonical-axis count mismatch)",
2399 axes.len()
2400 );
2401 }
2402 Ok(Some(axes))
2403 }
2404
2405 fn joint_jeffreys_information_second_directional_all_axes_with_specs(
2406 &self,
2407 block_states: &[ParameterBlockState],
2408 specs: &[ParameterBlockSpec],
2409 d_beta_u_flat: &Array1<f64>,
2410 ) -> Result<Option<Vec<Array2<f64>>>, String> {
2411 let eta = self.collect_eta_matrix(block_states)?;
2423 let axes =
2424 self.assemble_all_axis_second_directional_derivatives(eta.view(), d_beta_u_flat)?;
2425 let p: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
2430 if axes.len() != p {
2431 log::warn!(
2432 "multinomial all-axes second Jeffreys derivative produced {} axes but the block \
2433 specs describe p={p} joint coefficients (canonical-axis count mismatch)",
2434 axes.len()
2435 );
2436 }
2437 Ok(Some(axes))
2438 }
2439
2440 fn exact_newton_joint_hessiansecond_directional_derivative(
2441 &self,
2442 block_states: &[ParameterBlockState],
2443 d_beta_u_flat: &Array1<f64>,
2444 d_beta_v_flat: &Array1<f64>,
2445 ) -> Result<Option<Array2<f64>>, String> {
2446 let eta = self.collect_eta_matrix(block_states)?;
2447 let d2h_fisher =
2448 self.second_directional_fisher_jet(eta.view(), d_beta_u_flat, d_beta_v_flat)?;
2449 let d2h = dense_block_xtwx(self.design.view(), d2h_fisher.view(), None)
2450 .map_err(|e| format!("MultinomialFamily second directional H assembly: {e}"))?;
2451 Ok(Some(d2h))
2452 }
2453}
2454
2455struct MultinomialHessianWorkspace {
2464 family: MultinomialFamily,
2465 block_states: Vec<ParameterBlockState>,
2466 eta: Array2<f64>,
2470 probs: Array2<f64>,
2475}
2476
2477impl ExactNewtonJointHessianWorkspace for MultinomialHessianWorkspace {
2478 fn warm_up_outer_caches_for_mode(
2479 &self,
2480 eval_mode: gam_problem::EvalMode,
2481 ) -> Result<(), String> {
2482 match eval_mode {
2483 gam_problem::EvalMode::ValueOnly
2484 | gam_problem::EvalMode::ValueAndGradient
2485 | gam_problem::EvalMode::ValueGradientHessian => Ok(()),
2486 }
2487 }
2488
2489 fn hessian_dense(&self) -> Result<Option<Array2<f64>>, String> {
2490 self.family.exact_newton_joint_hessian(&self.block_states)
2491 }
2492
2493 fn hessian_source_preference(&self) -> JointHessianSourcePreference {
2494 JointHessianSourcePreference::Operator
2501 }
2502
2503 fn joint_log_likelihood_evaluation(&self) -> Result<Option<f64>, String> {
2504 let (log_lik, _) = self
2505 .family
2506 .joint_loglik_and_gradient_from_probs(self.eta.view(), self.probs.view())?;
2507 Ok(Some(log_lik))
2508 }
2509
2510 fn joint_gradient_evaluation(
2511 &self,
2512 ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
2513 let (log_likelihood, gradient) = self
2514 .family
2515 .joint_loglik_and_gradient_from_probs(self.eta.view(), self.probs.view())?;
2516 Ok(Some(ExactNewtonJointGradientEvaluation {
2517 log_likelihood,
2518 gradient,
2519 }))
2520 }
2521
2522 fn hessian_matvec_available(&self) -> bool {
2523 true
2524 }
2525
2526 fn hessian_matvec(&self, v: &Array1<f64>) -> Result<Option<Array1<f64>>, String> {
2527 let mut out = Array1::<f64>::zeros(self.family.beta_flat_dim());
2528 self.family
2529 .hessian_matvec_into_with_probs(self.probs.view(), v, &mut out)?;
2530 Ok(Some(out))
2531 }
2532
2533 fn hessian_matvec_into(&self, v: &Array1<f64>, out: &mut Array1<f64>) -> Result<bool, String> {
2534 self.family
2535 .hessian_matvec_into_with_probs(self.probs.view(), v, out)?;
2536 Ok(true)
2537 }
2538
2539 fn hessian_diagonal(&self) -> Result<Option<Array1<f64>>, String> {
2540 Ok(Some(
2541 self.family.hessian_diagonal_with_probs(self.probs.view()),
2542 ))
2543 }
2544
2545 fn directional_derivative(
2546 &self,
2547 d_beta_flat: &Array1<f64>,
2548 ) -> Result<Option<Array2<f64>>, String> {
2549 self.family
2550 .exact_newton_joint_hessian_directional_derivative(&self.block_states, d_beta_flat)
2551 }
2552
2553 fn directional_derivative_operators(
2554 &self,
2555 d_beta_flats: &[Array1<f64>],
2556 ) -> Result<Vec<Option<Arc<dyn HyperOperator>>>, String> {
2557 let probs = self.probs.view();
2564 d_beta_flats
2565 .iter()
2566 .map(|direction| {
2567 self.family
2568 .directional_hyper_operator(probs, direction)
2569 .map(|op| Some(Arc::new(op) as Arc<dyn HyperOperator>))
2570 })
2571 .collect()
2572 }
2573
2574 fn second_directional_derivative(
2575 &self,
2576 d_beta_u: &Array1<f64>,
2577 d_beta_v: &Array1<f64>,
2578 ) -> Result<Option<Array2<f64>>, String> {
2579 self.family
2580 .exact_newton_joint_hessiansecond_directional_derivative(
2581 &self.block_states,
2582 d_beta_u,
2583 d_beta_v,
2584 )
2585 }
2586
2587 fn second_directional_derivative_operators(
2588 &self,
2589 d_beta_pairs: &[(Array1<f64>, Array1<f64>)],
2590 ) -> Result<Vec<Option<Arc<dyn HyperOperator>>>, String> {
2591 let probs = self.probs.view();
2594 d_beta_pairs
2595 .iter()
2596 .map(|(u, v)| {
2597 self.family
2598 .second_directional_hyper_operator(probs, u, v)
2599 .map(|op| Some(Arc::new(op) as Arc<dyn HyperOperator>))
2600 })
2601 .collect()
2602 }
2603}
2604
2605struct MultinomialDirectionalHyperOperator {
2635 design: Arc<Array2<f64>>,
2637 jet: Array3<f64>,
2639 m: usize,
2641 p: usize,
2643}
2644
2645impl HyperOperator for MultinomialDirectionalHyperOperator {
2646 fn dim(&self) -> usize {
2647 self.m * self.p
2648 }
2649
2650 fn as_any(&self) -> &(dyn std::any::Any + 'static) {
2651 self
2652 }
2653
2654 fn is_implicit(&self) -> bool {
2655 false
2656 }
2657
2658 fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
2659 let dim = self.m * self.p;
2660 assert_eq!(v.len(), dim);
2661 let design = self.design.view();
2662 let n = design.nrows();
2663 let (m, p) = (self.m, self.p);
2664 let mut out = Array1::<f64>::zeros(dim);
2665 let mut t = vec![0.0_f64; m];
2666 let mut u = vec![0.0_f64; m];
2667 for row in 0..n {
2668 for b in 0..m {
2670 let base = b * p;
2671 let mut acc = 0.0_f64;
2672 for i in 0..p {
2673 acc += design[[row, i]] * v[base + i];
2674 }
2675 t[b] = acc;
2676 }
2677 for a in 0..m {
2679 let mut acc = 0.0_f64;
2680 for b in 0..m {
2681 acc += self.jet[[row, a, b]] * t[b];
2682 }
2683 u[a] = acc;
2684 }
2685 for a in 0..m {
2687 let ua = u[a];
2688 if ua == 0.0 {
2689 continue;
2690 }
2691 let base = a * p;
2692 for i in 0..p {
2693 out[base + i] += ua * design[[row, i]];
2694 }
2695 }
2696 }
2697 out
2698 }
2699
2700 fn projected_matrix(&self, factor: &Array2<f64>) -> Array2<f64> {
2701 let dim = self.m * self.p;
2702 assert_eq!(factor.nrows(), dim);
2703 let rank = factor.ncols();
2704 let design = self.design.view();
2705 let n = design.nrows();
2706 let (m, p) = (self.m, self.p);
2707 let mut out = Array2::<f64>::zeros((rank, rank));
2708 let mut g = Array2::<f64>::zeros((m, rank));
2711 let mut jg = Array2::<f64>::zeros((m, rank));
2712 for row in 0..n {
2713 for a in 0..m {
2714 let base = a * p;
2715 for k in 0..rank {
2716 let mut acc = 0.0_f64;
2717 for i in 0..p {
2718 acc += design[[row, i]] * factor[[base + i, k]];
2719 }
2720 g[[a, k]] = acc;
2721 }
2722 }
2723 for a in 0..m {
2724 for l in 0..rank {
2725 let mut acc = 0.0_f64;
2726 for b in 0..m {
2727 acc += self.jet[[row, a, b]] * g[[b, l]];
2728 }
2729 jg[[a, l]] = acc;
2730 }
2731 }
2732 for k in 0..rank {
2733 for l in 0..rank {
2734 let mut acc = 0.0_f64;
2735 for a in 0..m {
2736 acc += g[[a, k]] * jg[[a, l]];
2737 }
2738 out[[k, l]] += acc;
2739 }
2740 }
2741 }
2742 out
2743 }
2744
2745 fn trace_projected_factor(&self, factor: &Array2<f64>) -> f64 {
2746 self.projected_matrix(factor).diag().sum()
2748 }
2749
2750 fn to_dense(&self) -> Array2<f64> {
2751 let dim = self.m * self.p;
2753 let design = self.design.view();
2754 let n = design.nrows();
2755 let (m, p) = (self.m, self.p);
2756 let mut out = Array2::<f64>::zeros((dim, dim));
2757 for row in 0..n {
2758 for a in 0..m {
2759 for b in 0..m {
2760 let jab = self.jet[[row, a, b]];
2761 if jab == 0.0 {
2762 continue;
2763 }
2764 let ra = a * p;
2765 let rb = b * p;
2766 for i in 0..p {
2767 let xi = design[[row, i]];
2768 if xi == 0.0 {
2769 continue;
2770 }
2771 let scaled = jab * xi;
2772 for j in 0..p {
2773 out[[ra + i, rb + j]] += scaled * design[[row, j]];
2774 }
2775 }
2776 }
2777 }
2778 }
2779 out
2780 }
2781}
2782
2783#[cfg(test)]
2784mod tests {
2785 use super::*;
2800 use gam_problem::DenseMatrixHyperOperator;
2801 use ndarray::array;
2802
2803 mod jet_single_source_932 {
2816 use super::*;
2817 use gam_math::jet_tower::{
2818 program_fourth_contracted, program_row_kernel, program_third_contracted,
2819 };
2820 use std::sync::Arc;
2821
2822 fn single_row_family(obs: usize, w: f64, k: usize) -> MultinomialFamily {
2828 let mut y = Array2::<f64>::zeros((1, k));
2829 y[[0, obs]] = 1.0;
2830 let design = Arc::new(array![[1.0_f64]]);
2831 MultinomialFamily::new(y, array![w], k, design, Arc::new(Vec::new()))
2832 .expect("single-row multinomial family")
2833 }
2834
2835 fn single_row_family_response(response: &[f64], w: f64) -> MultinomialFamily {
2836 let y = Array2::from_shape_vec((1, response.len()), response.to_vec())
2837 .expect("single-row simplex response");
2838 MultinomialFamily::new(
2839 y,
2840 array![w],
2841 response.len(),
2842 Arc::new(array![[1.0_f64]]),
2843 Arc::new(Vec::new()),
2844 )
2845 .expect("single-row multinomial family with simplex response")
2846 }
2847
2848 struct Lcg(u64);
2850 impl Lcg {
2851 fn f64(&mut self) -> f64 {
2852 self.0 = self
2853 .0
2854 .wrapping_mul(6364136223846793005)
2855 .wrapping_add(1442695040888963407);
2856 ((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
2857 }
2858 fn uniform(&mut self, lo: f64, hi: f64) -> f64 {
2859 lo + (hi - lo) * self.f64()
2860 }
2861 }
2862
2863 const JET_TOL: f64 = 1e-9;
2864
2865 fn close(a: f64, b: f64, tol: f64, label: &str) {
2866 let band = tol + tol * a.abs().max(b.abs());
2867 assert!(
2868 (a - b).abs() <= band,
2869 "{label}: {a:+.15e} vs {b:+.15e} (|Δ|={:.3e} band {band:.3e})",
2870 (a - b).abs()
2871 );
2872 }
2873
2874 fn active_probs<const M: usize>(
2877 family: &MultinomialFamily,
2878 eta: &[f64; M],
2879 ) -> ndarray::Array2<f64> {
2880 let eta2 = Array2::<f64>::from_shape_vec((1, M), eta.to_vec()).expect("eta (1,M)");
2881 family.row_probabilities(eta2.view())
2882 }
2883
2884 fn prod_third<const M: usize>(
2887 family: &MultinomialFamily,
2888 eta: &[f64; M],
2889 dir: &[f64; M],
2890 ) -> [[f64; M]; M] {
2891 let probs = active_probs(family, eta);
2892 let d = Array1::from(dir.to_vec());
2893 let j = family.directional_fisher_jet_rows(probs.view(), &d);
2894 std::array::from_fn(|a| std::array::from_fn(|b| j[[0, a, b]]))
2895 }
2896
2897 fn prod_fourth<const M: usize>(
2900 family: &MultinomialFamily,
2901 eta: &[f64; M],
2902 u: &[f64; M],
2903 v: &[f64; M],
2904 ) -> [[f64; M]; M] {
2905 let probs = active_probs(family, eta);
2906 let ua = Array1::from(u.to_vec());
2907 let va = Array1::from(v.to_vec());
2908 let j = family.second_directional_fisher_jet_rows(probs.view(), &ua, &va);
2909 std::array::from_fn(|a| std::array::from_fn(|b| j[[0, a, b]]))
2910 }
2911
2912 fn prod_hessian<const M: usize>(
2915 family: &MultinomialFamily,
2916 eta: &[f64; M],
2917 ) -> [[f64; M]; M] {
2918 let probs = active_probs(family, eta);
2919 let mut h = [[0.0_f64; M]; M];
2920 for col in 0..M {
2921 let mut e = Array1::<f64>::zeros(M);
2922 e[col] = 1.0;
2923 let mut out = Array1::<f64>::zeros(M);
2924 family
2925 .hessian_matvec_into_with_probs(probs.view(), &e, &mut out)
2926 .expect("prod hessian matvec");
2927 for row in 0..M {
2928 h[row][col] = out[row];
2929 }
2930 }
2931 h
2932 }
2933
2934 fn run_parity<const M: usize>(seed: u64) {
2935 let mut rng = Lcg(seed);
2936 for trial in 0..24 {
2937 let eta: [f64; M] = std::array::from_fn(|_| rng.uniform(-2.0, 2.0));
2938 let obs = trial % (M + 1);
2939 let w = rng.uniform(0.25, 2.5);
2940 let family = single_row_family(obs, w, M + 1);
2941 let mut response = vec![0.0; M + 1];
2942 response[obs] = 1.0;
2943 let prog =
2944 crate::multinomial_reml::MultinomialLogitRowProgram::new(&eta, &response, w)
2945 .expect("valid multinomial row program");
2946
2947 let (jet_v, jet_g, jet_h) =
2949 program_row_kernel::<M, _>(&prog, 0).expect("jet row kernel");
2950
2951 let probs = active_probs(&family, &eta);
2954 let eta_matrix = Array2::from_shape_vec((1, M), eta.to_vec()).expect("eta matrix");
2955 let (log_lik, grad_ll) = family
2956 .joint_loglik_and_gradient_from_probs(eta_matrix.view(), probs.view())
2957 .expect("valid frozen multinomial row");
2958 close(
2959 jet_v,
2960 -log_lik,
2961 JET_TOL,
2962 &format!("M={M} trial {trial} value"),
2963 );
2964 for a in 0..M {
2965 close(
2966 jet_g[a],
2967 -grad_ll[a],
2968 JET_TOL,
2969 &format!("M={M} trial {trial} grad[{a}]"),
2970 );
2971 }
2972
2973 let prod_h = prod_hessian(&family, &eta);
2975 for a in 0..M {
2976 for b in 0..M {
2977 close(
2978 jet_h[a][b],
2979 prod_h[a][b],
2980 JET_TOL,
2981 &format!("M={M} trial {trial} H[{a}][{b}]"),
2982 );
2983 }
2984 }
2985
2986 let dir: [f64; M] = std::array::from_fn(|_| rng.uniform(-1.5, 1.5));
2988 let u: [f64; M] = std::array::from_fn(|_| rng.uniform(-1.5, 1.5));
2989 let jet_third = program_third_contracted(&prog, 0, &dir).expect("jet third");
2990 let prod_t3 = prod_third(&family, &eta, &dir);
2991 let jet_fourth = program_fourth_contracted(&prog, 0, &u, &dir).expect("jet fourth");
2992 let prod_t4 = prod_fourth(&family, &eta, &u, &dir);
2993 for a in 0..M {
2994 for b in 0..M {
2995 close(
2996 jet_third[a][b],
2997 prod_t3[a][b],
2998 JET_TOL,
2999 &format!("M={M} trial {trial} third[{a}][{b}]"),
3000 );
3001 close(
3002 jet_fourth[a][b],
3003 prod_t4[a][b],
3004 JET_TOL,
3005 &format!("M={M} trial {trial} fourth[{a}][{b}]"),
3006 );
3007 }
3008 }
3009
3010 let h_fd = 1e-4;
3013 let eta_p: [f64; M] = std::array::from_fn(|a| eta[a] + h_fd * dir[a]);
3014 let eta_m: [f64; M] = std::array::from_fn(|a| eta[a] - h_fd * dir[a]);
3015 let hp = prod_hessian(&family, &eta_p);
3016 let hm = prod_hessian(&family, &eta_m);
3017 for a in 0..M {
3018 for b in 0..M {
3019 let fd = (hp[a][b] - hm[a][b]) / (2.0 * h_fd);
3020 close(
3021 prod_t3[a][b],
3022 fd,
3023 1e-6,
3024 &format!("M={M} trial {trial} FD third[{a}][{b}]"),
3025 );
3026 }
3027 }
3028 let t3_up = prod_third(&family, &eta_p_along(&eta, &u, h_fd), &dir);
3031 let t3_um = prod_third(&family, &eta_m_along(&eta, &u, h_fd), &dir);
3032 for a in 0..M {
3033 for b in 0..M {
3034 let fd = (t3_up[a][b] - t3_um[a][b]) / (2.0 * h_fd);
3035 close(
3036 prod_t4[a][b],
3037 fd,
3038 1e-6,
3039 &format!("M={M} trial {trial} FD fourth[{a}][{b}]"),
3040 );
3041 }
3042 }
3043 }
3044 }
3045
3046 fn eta_p_along<const M: usize>(eta: &[f64; M], u: &[f64; M], h: f64) -> [f64; M] {
3047 std::array::from_fn(|a| eta[a] + h * u[a])
3048 }
3049 fn eta_m_along<const M: usize>(eta: &[f64; M], u: &[f64; M], h: f64) -> [f64; M] {
3050 std::array::from_fn(|a| eta[a] - h * u[a])
3051 }
3052
3053 #[test]
3058 fn multinomial_live_tower_matches_jet_and_fd() {
3059 run_parity::<2>(0x9322_2020_0710_face);
3060 run_parity::<3>(0x0bad_c0de_0710_2020);
3061 }
3062
3063 #[test]
3069 fn multinomial_extreme_tails_share_one_stable_row_program_932() {
3070 const M: usize = 3;
3071 let cases = [
3072 ([1_000.0, -1_000.0, -750.0], [0.0, 0.0, 0.0, 1.0], 1.25),
3073 ([-1_000.0, -900.0, -800.0], [0.0, 0.0, 1.0, 0.0], 0.75),
3074 ([1_000.0, 1_000.0, -1_000.0], [0.2, 0.3, 0.1, 0.4], 2.0),
3075 ([f64::MAX, -f64::MAX, 0.0], [1.0, 0.0, 0.0, 0.0], 1.0),
3076 ([f64::MAX, -f64::MAX, 0.0], [0.0, 0.0, 0.0, 1.0], 0.0),
3077 ];
3078 let direction = [0.7, -0.4, 1.1];
3079 let direction_u = [-0.3, 0.9, 0.2];
3080
3081 for (case, (eta, response, weight)) in cases.into_iter().enumerate() {
3082 let program = MultinomialLogitRowProgram::new(&eta, &response, weight)
3083 .expect("valid extreme-tail row program");
3084 let (canonical_value, canonical_gradient, canonical_hessian) =
3085 program_row_kernel::<3, _>(&program, 0).expect("canonical extreme-tail V/G/H");
3086 let canonical_third = program_third_contracted(&program, 0, &direction)
3087 .expect("canonical extreme-tail third");
3088 let canonical_fourth =
3089 program_fourth_contracted(&program, 0, &direction_u, &direction)
3090 .expect("canonical extreme-tail fourth");
3091
3092 assert!(canonical_value.is_finite(), "case {case} value");
3093 assert!(
3094 canonical_gradient.iter().all(|value| value.is_finite()),
3095 "case {case} gradient"
3096 );
3097 assert!(
3098 canonical_hessian
3099 .iter()
3100 .flatten()
3101 .all(|value| value.is_finite()),
3102 "case {case} Hessian"
3103 );
3104 assert!(
3105 canonical_third
3106 .iter()
3107 .flatten()
3108 .all(|value| value.is_finite()),
3109 "case {case} third"
3110 );
3111 assert!(
3112 canonical_fourth
3113 .iter()
3114 .flatten()
3115 .all(|value| value.is_finite()),
3116 "case {case} fourth"
3117 );
3118
3119 let family = single_row_family_response(&response, weight);
3120 let eta_matrix =
3121 Array2::from_shape_vec((1, M), eta.to_vec()).expect("tail eta matrix");
3122 let response_matrix = Array2::from_shape_vec((1, M + 1), response.to_vec())
3123 .expect("tail response matrix");
3124 let (live_log_likelihood, live_gradient, live_hessian) = family
3125 .likelihood
3126 .value_gradient_hessian(eta_matrix.view(), response_matrix.view())
3127 .expect("valid multinomial tail row");
3128 close(
3129 canonical_value,
3130 -live_log_likelihood,
3131 1.0e-12,
3132 &format!("tail case {case} value"),
3133 );
3134 for row in 0..M {
3135 close(
3136 canonical_gradient[row],
3137 -live_gradient[[0, row]],
3138 1.0e-12,
3139 &format!("tail case {case} gradient[{row}]"),
3140 );
3141 for column in 0..M {
3142 close(
3143 canonical_hessian[row][column],
3144 live_hessian[[0, row, column]],
3145 1.0e-12,
3146 &format!("tail case {case} Hessian[{row}][{column}]"),
3147 );
3148 }
3149 }
3150
3151 let live_third = prod_third(&family, &eta, &direction);
3152 let live_fourth = prod_fourth(&family, &eta, &direction_u, &direction);
3153 for row in 0..M {
3154 for column in 0..M {
3155 close(
3156 canonical_third[row][column],
3157 live_third[row][column],
3158 1.0e-12,
3159 &format!("tail case {case} third[{row}][{column}]"),
3160 );
3161 close(
3162 canonical_fourth[row][column],
3163 live_fourth[row][column],
3164 1.0e-12,
3165 &format!("tail case {case} fourth[{row}][{column}]"),
3166 );
3167 }
3168 }
3169 }
3170 }
3171
3172 #[test]
3183 fn multinomial_m32_production_directional_routes_match_canonical_jet_932() {
3184 const REGRESSION_STACK_BYTES: usize = 1024 * 1024;
3185 let worker = std::thread::Builder::new()
3186 .name("multinomial-m32-canonical-stack-bound".to_string())
3187 .stack_size(REGRESSION_STACK_BYTES)
3188 .spawn(|| {
3189 const M: usize = 32;
3190 assert_eq!(
3191 M * std::mem::size_of::<gam_math::jet_scalar::TwoSeed<M>>(),
3192 1_082_368,
3193 "M=32 canonical fourth-order seed footprint changed"
3194 );
3195 let first_schedule = fisher_output_schedule::<OneSeed<0>>(M);
3196 let expected_first = if AVX2_WITHOUT_AVX512 {
3197 FisherOutputSchedule::ContiguousFull
3198 } else {
3199 FisherOutputSchedule::SymmetricTriangle
3200 };
3201 assert!(
3202 first_schedule == expected_first,
3203 "M=32 first-directional Fisher schedule does not match the target ISA"
3204 );
3205 assert!(
3206 fisher_output_schedule::<TwoSeed<0>>(M)
3207 == FisherOutputSchedule::SymmetricTriangle,
3208 "M=32 second-directional Fisher schedule must retain symmetric output"
3209 );
3210
3211 for trial in 0..4 {
3212 let eta: [f64; M] = std::array::from_fn(|axis| {
3213 0.9 * ((axis * 7 + trial * 3 + 1) as f64 * 0.17).sin()
3214 - 0.35 * ((axis + trial + 2) as f64 * 0.11).cos()
3215 });
3216 let direction: [f64; M] = std::array::from_fn(|axis| {
3217 0.7 * ((axis * 5 + trial + 3) as f64 * 0.13).cos()
3218 - 0.2 * ((axis + 2 * trial + 1) as f64 * 0.19).sin()
3219 });
3220 let direction_u: [f64; M] = std::array::from_fn(|axis| {
3221 -0.6 * ((axis * 3 + trial + 4) as f64 * 0.09).sin()
3222 + 0.25 * ((axis + trial + 5) as f64 * 0.23).cos()
3223 });
3224 let observed_class = if trial % 2 == 0 { trial } else { M };
3225 let weight = 0.8 + 0.3 * trial as f64;
3226 let family = single_row_family(observed_class, weight, M + 1);
3227 let mut response = vec![0.0; M + 1];
3228 response[observed_class] = 1.0;
3229 let program = MultinomialLogitRowProgram::new(&eta, &response, weight)
3230 .expect("valid M=32 multinomial row program");
3231
3232 let production_first = prod_third(&family, &eta, &direction);
3233 let canonical_first = program_third_contracted(&program, 0, &direction)
3234 .expect("canonical M=32 first-directional Fisher contraction");
3235 let production_second =
3236 prod_fourth(&family, &eta, &direction_u, &direction);
3237 let canonical_second =
3238 program_fourth_contracted(&program, 0, &direction_u, &direction)
3239 .expect("canonical M=32 second-directional Fisher contraction");
3240
3241 for row in 0..M {
3242 for column in 0..M {
3243 close(
3244 production_first[row][column],
3245 canonical_first[row][column],
3246 JET_TOL,
3247 &format!(
3248 "M=32 trial {trial} first-directional[{row}][{column}]"
3249 ),
3250 );
3251 close(
3252 production_second[row][column],
3253 canonical_second[row][column],
3254 JET_TOL,
3255 &format!(
3256 "M=32 trial {trial} second-directional[{row}][{column}]"
3257 ),
3258 );
3259 }
3260 }
3261 }
3262 })
3263 .expect("spawn bounded-stack M=32 parity worker");
3264 if let Err(payload) = worker.join() {
3265 std::panic::resume_unwind(payload);
3266 }
3267 }
3268
3269 #[test]
3286 fn release_measure_multinomial_specialized_vs_generic_tower_932() {
3287 fn measure<const M: usize>(seed: u64) {
3288 use std::time::Instant;
3289
3290 const ROWS: usize = 512;
3291 let mut rng = Lcg(seed);
3292 let mut etas: Vec<[f64; M]> = Vec::with_capacity(ROWS);
3293 let mut responses: Vec<Vec<f64>> = Vec::with_capacity(ROWS);
3294 let mut weights: Vec<f64> = Vec::with_capacity(ROWS);
3295 for row in 0..ROWS {
3296 let eta: [f64; M] = std::array::from_fn(|_| rng.uniform(-2.5, 2.5));
3297 let observed = row % (M + 1);
3298 let mut response = vec![0.0; M + 1];
3299 response[observed] = 1.0;
3300 etas.push(eta);
3301 responses.push(response);
3302 weights.push(rng.uniform(0.25, 2.5));
3303 }
3304 let programs: Vec<MultinomialLogitRowProgram> = (0..ROWS)
3305 .map(|row| {
3306 MultinomialLogitRowProgram::new(&etas[row], &responses[row], weights[row])
3307 .expect("valid multinomial batch row")
3308 })
3309 .collect();
3310
3311 let mut probabilities = vec![0.0_f64; M + 1];
3312 let mut gradient = vec![0.0_f64; M];
3313 let mut hessian = vec![0.0_f64; M * M];
3314
3315 for program in &programs {
3319 let (tower_value, tower_gradient, tower_hessian) =
3320 program_row_kernel::<M, _>(program, 0).expect("tower warm kernel");
3321 let production_value = program.value_gradient_hessian_into(
3322 &mut probabilities,
3323 &mut gradient,
3324 &mut hessian,
3325 );
3326 close(
3327 tower_value,
3328 production_value,
3329 JET_TOL,
3330 &format!("M={M} release-measure value parity"),
3331 );
3332 for a in 0..M {
3333 close(
3334 tower_gradient[a],
3335 gradient[a],
3336 JET_TOL,
3337 &format!("M={M} release-measure gradient[{a}] parity"),
3338 );
3339 for b in 0..M {
3340 close(
3341 tower_hessian[a][b],
3342 hessian[a * M + b],
3343 JET_TOL,
3344 &format!("M={M} release-measure hessian[{a}][{b}] parity"),
3345 );
3346 }
3347 }
3348 }
3349
3350 let best_secs = |sweep: &mut dyn FnMut() -> f64| -> f64 {
3351 let mut best = f64::INFINITY;
3352 for _ in 0..5 {
3353 let started = Instant::now();
3354 let checksum = sweep();
3355 assert!(
3356 checksum.is_finite(),
3357 "multinomial release-measure checksum must stay finite"
3358 );
3359 best = best.min(started.elapsed().as_secs_f64());
3360 }
3361 best
3362 };
3363
3364 let mut production_sweep = || {
3365 let mut checksum = 0.0_f64;
3366 for program in &programs {
3367 let value = program.value_gradient_hessian_into(
3368 &mut probabilities,
3369 &mut gradient,
3370 &mut hessian,
3371 );
3372 checksum += value + gradient[0] + hessian[0];
3373 }
3374 checksum
3375 };
3376 let production_secs = best_secs(&mut production_sweep);
3377
3378 let mut tower_sweep = || {
3379 let mut checksum = 0.0_f64;
3380 for program in &programs {
3381 let (value, tower_gradient, tower_hessian) =
3382 program_row_kernel::<M, _>(program, 0).expect("tower kernel");
3383 checksum += value + tower_gradient[0] + tower_hessian[0][0];
3384 }
3385 checksum
3386 };
3387 let tower_secs = best_secs(&mut tower_sweep);
3388
3389 let production_ns = production_secs * 1e9 / ROWS as f64;
3390 let tower_ns = tower_secs * 1e9 / ROWS as f64;
3391 eprintln!(
3392 "MULTINOMIAL-RELEASE-932 M={M} rows={ROWS} production_ns={production_ns:.3} \
3393 generic_tower_ns={tower_ns:.3} hand_over_production={:.6}",
3394 tower_ns / production_ns,
3395 );
3396 }
3397
3398 measure::<2>(0x9322_2020_0715_face);
3399 measure::<3>(0x0bad_c0de_0715_2020);
3400 measure::<4>(0x5eed_4444_0722_beef);
3401 measure::<8>(0x1234_5678_0715_abcd);
3402 }
3403 }
3404
3405 impl MultinomialFamily {
3406 fn assemble_directional_derivatives(
3412 &self,
3413 eta: ArrayView2<'_, f64>,
3414 directions: &[Array1<f64>],
3415 ) -> Result<Vec<Array2<f64>>, String> {
3416 let probs = self.row_probabilities(eta);
3417 self.assemble_directional_derivatives_from_probs(probs.view(), directions)
3418 }
3419
3420 fn assemble_directional_derivatives_from_probs(
3435 &self,
3436 probs_full: ArrayView2<'_, f64>,
3437 directions: &[Array1<f64>],
3438 ) -> Result<Vec<Array2<f64>>, String> {
3439 use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
3440
3441 let n_dirs = directions.len();
3442 if n_dirs == 0 {
3443 return Ok(Vec::new());
3444 }
3445 let n = self.weights.len();
3446 let p = self.design.ncols();
3447 let m = self.active_classes();
3448 let dim = m * p;
3449 for (idx, direction) in directions.iter().enumerate() {
3450 if direction.len() != dim {
3451 return Err(format!(
3452 "MultinomialFamily batched direction {idx} length {} != (K-1)·P = {dim}",
3453 direction.len()
3454 ));
3455 }
3456 }
3457 let design = self.design.view();
3458 let out: Vec<Array2<f64>> = directions
3465 .par_iter()
3466 .map(|direction| {
3467 let mut mat = vec![0.0_f64; dim * dim];
3468 let mut d_eta = vec![0.0_f64; m];
3469 let mut dp = vec![0.0_f64; m];
3470 for row in 0..n {
3471 let w = self.weights[row];
3472 if w == 0.0 {
3473 continue;
3474 }
3475 let mut s = 0.0_f64;
3476 for a in 0..m {
3477 let base = a * p;
3478 let mut eta_dir = 0.0_f64;
3479 for i in 0..p {
3480 eta_dir += design[[row, i]] * direction[base + i];
3481 }
3482 d_eta[a] = eta_dir;
3483 s += probs_full[[row, a]] * eta_dir;
3484 }
3485 for a in 0..m {
3486 dp[a] = probs_full[[row, a]] * (d_eta[a] - s);
3487 }
3488
3489 for a in 0..m {
3490 let pa = probs_full[[row, a]];
3491 let row_a = a * p;
3492 let jaa = w * (dp[a] - 2.0 * dp[a] * pa);
3493 if jaa != 0.0 {
3494 for i in 0..p {
3495 let xi = design[[row, i]];
3496 if xi == 0.0 {
3497 continue;
3498 }
3499 let scaled = jaa * xi;
3500 let out_row = (row_a + i) * dim;
3501 for j in 0..p {
3502 mat[out_row + row_a + j] += scaled * design[[row, j]];
3503 }
3504 }
3505 }
3506 for b in (a + 1)..m {
3507 let pb = probs_full[[row, b]];
3508 let jab = w * (-(dp[a] * pb + pa * dp[b]));
3509 if jab == 0.0 {
3510 continue;
3511 }
3512 let row_b = b * p;
3513 for i in 0..p {
3514 let xi = design[[row, i]];
3515 if xi == 0.0 {
3516 continue;
3517 }
3518 let scaled = jab * xi;
3519 let out_a = (row_a + i) * dim;
3520 let out_b = (row_b + i) * dim;
3521 for j in 0..p {
3522 let xj = design[[row, j]];
3523 let value = scaled * xj;
3524 mat[out_a + row_b + j] += value;
3525 mat[out_b + row_a + j] += value;
3526 }
3527 }
3528 }
3529 }
3530 }
3531 let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
3532 .expect("batched direction derivative buffer is dim·dim");
3533 for i in 0..dim {
3534 for j in (i + 1)..dim {
3535 let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
3536 mat[[i, j]] = avg;
3537 mat[[j, i]] = avg;
3538 }
3539 }
3540 mat
3541 })
3542 .collect();
3543 Ok(out)
3544 }
3545
3546 fn assemble_second_directional_derivatives_from_probs(
3560 &self,
3561 probs_full: ArrayView2<'_, f64>,
3562 pairs: &[(Array1<f64>, Array1<f64>)],
3563 ) -> Result<Vec<Array2<f64>>, String> {
3564 use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
3565
3566 let n_pairs = pairs.len();
3567 if n_pairs == 0 {
3568 return Ok(Vec::new());
3569 }
3570 let n = self.weights.len();
3571 let p = self.design.ncols();
3572 let m = self.active_classes();
3573 let dim = m * p;
3574 for (idx, (u, v)) in pairs.iter().enumerate() {
3575 if u.len() != dim || v.len() != dim {
3576 return Err(format!(
3577 "MultinomialFamily batched second-directional pair {idx} lengths {} and {} != (K-1)·P = {dim}",
3578 u.len(),
3579 v.len()
3580 ));
3581 }
3582 }
3583
3584 let design = self.design.view();
3585 let out: Vec<Array2<f64>> = pairs
3593 .par_iter()
3594 .map(|(u, v)| {
3595 let mut mat = vec![0.0_f64; dim * dim];
3596 let mut d_eta_u = vec![0.0_f64; m];
3597 let mut d_eta_v = vec![0.0_f64; m];
3598 let mut dp_u = vec![0.0_f64; m];
3599 let mut dp_v = vec![0.0_f64; m];
3600 let mut ddp = vec![0.0_f64; m];
3601 for row in 0..n {
3602 let w = self.weights[row];
3603 if w == 0.0 {
3604 continue;
3605 }
3606 let mut s_u = 0.0_f64;
3607 let mut s_v = 0.0_f64;
3608 for a in 0..m {
3609 let base = a * p;
3610 let mut eta_u = 0.0_f64;
3611 let mut eta_v = 0.0_f64;
3612 for i in 0..p {
3613 let x = design[[row, i]];
3614 eta_u += x * u[base + i];
3615 eta_v += x * v[base + i];
3616 }
3617 d_eta_u[a] = eta_u;
3618 d_eta_v[a] = eta_v;
3619 s_u += probs_full[[row, a]] * eta_u;
3620 s_v += probs_full[[row, a]] * eta_v;
3621 }
3622
3623 for a in 0..m {
3624 let pa = probs_full[[row, a]];
3625 dp_u[a] = pa * (d_eta_u[a] - s_u);
3626 dp_v[a] = pa * (d_eta_v[a] - s_v);
3627 }
3628
3629 let mut ds_u_dv = 0.0_f64;
3630 for a in 0..m {
3631 ds_u_dv += dp_v[a] * d_eta_u[a];
3632 }
3633 for a in 0..m {
3634 let pa = probs_full[[row, a]];
3635 ddp[a] = dp_v[a] * (d_eta_u[a] - s_u) - pa * ds_u_dv;
3636 }
3637
3638 for a in 0..m {
3639 let pa = probs_full[[row, a]];
3640 let row_a = a * p;
3641 let jaa = w * (ddp[a] - 2.0 * ddp[a] * pa - 2.0 * dp_u[a] * dp_v[a]);
3642 if jaa != 0.0 {
3643 for i in 0..p {
3644 let xi = design[[row, i]];
3645 if xi == 0.0 {
3646 continue;
3647 }
3648 let scaled = jaa * xi;
3649 let out_row = (row_a + i) * dim;
3650 for j in 0..p {
3651 mat[out_row + row_a + j] += scaled * design[[row, j]];
3652 }
3653 }
3654 }
3655
3656 for b in (a + 1)..m {
3657 let pb = probs_full[[row, b]];
3658 let jab = -w
3659 * (ddp[a] * pb
3660 + dp_u[a] * dp_v[b]
3661 + dp_v[a] * dp_u[b]
3662 + pa * ddp[b]);
3663 if jab == 0.0 {
3664 continue;
3665 }
3666 let row_b = b * p;
3667 for i in 0..p {
3668 let xi = design[[row, i]];
3669 if xi == 0.0 {
3670 continue;
3671 }
3672 let scaled = jab * xi;
3673 let out_a = (row_a + i) * dim;
3674 let out_b = (row_b + i) * dim;
3675 for j in 0..p {
3676 let xj = design[[row, j]];
3677 let value = scaled * xj;
3678 mat[out_a + row_b + j] += value;
3679 mat[out_b + row_a + j] += value;
3680 }
3681 }
3682 }
3683 }
3684 }
3685 let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
3686 .expect("batched second-directional buffer is dim·dim");
3687 for i in 0..dim {
3688 for j in (i + 1)..dim {
3689 let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
3690 mat[[i, j]] = avg;
3691 mat[[j, i]] = avg;
3692 }
3693 }
3694 mat
3695 })
3696 .collect();
3697 Ok(out)
3698 }
3699 }
3700
3701 fn toy_family(n_obs: usize, p: usize, k: usize) -> MultinomialFamily {
3702 let y = {
3703 let mut y = Array2::<f64>::zeros((n_obs, k));
3704 for i in 0..n_obs {
3705 y[[i, i % k]] = 1.0;
3706 }
3707 y
3708 };
3709 let weights = Array1::<f64>::ones(n_obs);
3710 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
3711 ((i + j + 1) as f64).sin()
3712 }));
3713 let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(
3714 Array2::<f64>::from_shape_fn((p, p), |(i, j)| if i == j { 1.0 } else { 0.0 }),
3715 )]);
3716 MultinomialFamily::new(y, weights, k, design, penalties)
3717 .expect("toy MultinomialFamily must construct")
3718 }
3719
3720 #[test]
3721 fn block_specs_have_one_per_active_class_in_order() {
3722 let family = toy_family(8, 3, 4);
3723 let specs = family.build_block_specs();
3724 assert_eq!(specs.len(), 3, "expected K-1 = 3 active blocks for K=4");
3725 for (a, spec) in specs.iter().enumerate() {
3726 assert_eq!(spec.name, format!("class_{a}"));
3727 }
3728 }
3729
3730 #[test]
3731 fn gauge_priority_is_strictly_decreasing_in_class_index() {
3732 let family = toy_family(8, 3, 5);
3733 let specs = family.build_block_specs();
3734 for window in specs.windows(2) {
3735 assert!(
3736 window[0].gauge_priority > window[1].gauge_priority,
3737 "class_{} priority {} must exceed class_{} priority {}",
3738 window[0].name,
3739 window[0].gauge_priority,
3740 window[1].name,
3741 window[1].gauge_priority,
3742 );
3743 }
3744 }
3745
3746 #[test]
3747 fn block_specs_share_design_shape_with_family() {
3748 let family = toy_family(8, 3, 4);
3749 let specs = family.build_block_specs();
3750 let (n, p) = (family.design.nrows(), family.design.ncols());
3751 for spec in &specs {
3752 assert_eq!(spec.design.nrows(), n);
3753 assert_eq!(spec.design.ncols(), p);
3754 }
3755 }
3756
3757 #[test]
3758 fn per_term_smoothing_is_carried_by_equivariant_class_penalties() {
3759 let single = toy_family(6, 4, 3);
3760 for spec in &single.build_block_specs() {
3761 assert!(
3762 spec.penalties.is_empty()
3763 && spec.initial_log_lambdas.is_empty()
3764 && spec.nullspace_dims.is_empty(),
3765 "per-class blocks must attach no smooth penalty — the ALR-anchored \
3766 per-block carrier is reference-dependent (#1587); the equivariant \
3767 per-class centered joint family is the sole carrier"
3768 );
3769 }
3770 let joint = single.joint_penalty_specs().expect("joint specs");
3771 assert_eq!(
3772 joint.len(),
3773 3, "one per-class centered penalty per (term, class), reference included"
3775 );
3776
3777 let p = 5;
3778 let k = 4;
3779 let n_terms = 3;
3780 let n_obs = 9;
3781 let y = {
3782 let mut y = Array2::<f64>::zeros((n_obs, k));
3783 for i in 0..n_obs {
3784 y[[i, i % k]] = 1.0;
3785 }
3786 y
3787 };
3788 let weights = Array1::<f64>::ones(n_obs);
3789 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
3790 ((i + j + 1) as f64).cos()
3791 }));
3792 let penalties = Arc::new(
3793 (0..n_terms)
3794 .map(|t| {
3795 crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
3796 (p, p),
3797 |(i, j)| if i == j { (t + 1) as f64 } else { 0.0 },
3798 ))
3799 })
3800 .collect::<Vec<_>>(),
3801 );
3802 let multi = MultinomialFamily::new(y, weights, k, design, penalties)
3803 .expect("multi-term MultinomialFamily must construct");
3804 let specs = multi.build_block_specs();
3805 assert_eq!(specs.len(), k - 1, "one block per active class");
3806 for spec in &specs {
3807 assert!(spec.penalties.is_empty());
3808 assert!(spec.initial_log_lambdas.is_empty());
3809 assert!(spec.nullspace_dims.is_empty());
3810 }
3811 let joint = multi.joint_penalty_specs().expect("joint specs");
3812 assert_eq!(
3813 joint.len(),
3814 n_terms * k,
3815 "K per-class centered penalties per term, term-major"
3816 );
3817 let m = k - 1;
3818 let raw_total = m * p;
3819 for (t_idx, term_specs) in joint.chunks(k).enumerate() {
3820 let mut sum = Array2::<f64>::zeros((raw_total, raw_total));
3823 for (c, spec) in term_specs.iter().enumerate() {
3824 assert_eq!(
3825 spec.label.as_deref(),
3826 Some(format!("multinomial_term_{t_idx}_class_{c}").as_str())
3827 );
3828 assert_eq!(spec.nullspace_dim, raw_total - p);
3830 sum += &spec.matrix;
3831 }
3832 let centered = multi
3833 .centered_joint_penalty_specs()
3834 .expect("centered specs");
3835 let target = ¢ered[t_idx].matrix;
3836 let max_err = sum
3837 .iter()
3838 .zip(target.iter())
3839 .map(|(a, b)| (a - b).abs())
3840 .fold(0.0_f64, f64::max);
3841 assert!(
3842 max_err < 1e-14,
3843 "Σ_c C_cᵀC_c ⊗ S_t must equal M ⊗ S_t (max err {max_err:.2e})"
3844 );
3845 }
3846 }
3847
3848 #[test]
3849 fn block_specs_keep_independent_lambda_per_class_and_term() {
3850 let p = 5;
3851 let k = 4;
3852 let n_terms = 3;
3853 let n_obs = 9;
3854 let y = {
3855 let mut y = Array2::<f64>::zeros((n_obs, k));
3856 for i in 0..n_obs {
3857 y[[i, i % k]] = 1.0;
3858 }
3859 y
3860 };
3861 let weights = Array1::<f64>::ones(n_obs);
3862 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
3863 ((i + j + 1) as f64).cos()
3864 }));
3865 let penalties = Arc::new(
3866 (0..n_terms)
3867 .map(|t| {
3868 crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
3869 (p, p),
3870 |(i, j)| if i == j { (t + 1) as f64 } else { 0.0 },
3871 ))
3872 })
3873 .collect::<Vec<_>>(),
3874 );
3875 let multi = MultinomialFamily::new(y, weights, k, design, penalties)
3876 .expect("multi-term MultinomialFamily must construct");
3877 let specs = multi.build_block_specs();
3878 assert_eq!(specs.len(), k - 1);
3879 let joint = multi.joint_penalty_specs().expect("joint specs");
3883 assert_eq!(joint.len(), n_terms * k);
3884 let labels: Vec<&str> = joint.iter().filter_map(|s| s.label.as_deref()).collect();
3885 assert_eq!(
3886 labels.len(),
3887 n_terms * k,
3888 "every spec carries its own label"
3889 );
3890 let unique: std::collections::HashSet<&str> = labels.iter().copied().collect();
3891 assert_eq!(
3892 unique.len(),
3893 labels.len(),
3894 "distinct labels ⇒ one independent outer λ per (term, class)"
3895 );
3896 for spec in &specs {
3897 assert!(spec.penalties.is_empty());
3898 }
3899 }
3900
3901 #[test]
3902 fn collect_eta_matrix_rejects_wrong_block_count() {
3903 let family = toy_family(4, 2, 3);
3904 let single = vec![ParameterBlockState {
3905 beta: Array1::<f64>::zeros(2),
3906 eta: Array1::<f64>::zeros(4),
3907 }];
3908 assert!(family.collect_eta_matrix(&single).is_err());
3909 }
3910
3911 #[test]
3912 fn evaluate_uniform_eta_zero_matches_uniform_softmax() {
3913 let family = toy_family(5, 2, 3);
3914 let p = family.design.ncols();
3915 let m = family.active_classes();
3916 let n = family.weights.len();
3917 let block_states: Vec<ParameterBlockState> = (0..m)
3918 .map(|_| ParameterBlockState {
3919 beta: Array1::<f64>::zeros(p),
3920 eta: Array1::<f64>::zeros(n),
3921 })
3922 .collect();
3923 let eval = family
3924 .evaluate(&block_states)
3925 .expect("baseline evaluate must succeed at β = 0");
3926 let expected = (n as f64) * (1.0 / (family.total_classes as f64)).ln();
3927 let diff = (eval.log_likelihood - expected).abs();
3928 assert!(
3929 diff < 1.0e-10,
3930 "baseline log-lik {} != {}",
3931 eval.log_likelihood,
3932 expected,
3933 );
3934 assert_eq!(eval.blockworking_sets.len(), m);
3935 }
3936
3937 #[test]
3938 fn directional_fisher_jet_along_zero_vanishes() {
3939 let family = toy_family(4, 2, 3);
3940 let p = family.design.ncols();
3941 let m = family.active_classes();
3942 let n = family.weights.len();
3943 let eta = Array2::<f64>::zeros((n, m));
3944 let d_beta = Array1::<f64>::zeros(m * p);
3945 let jet = family
3946 .directional_fisher_jet(eta.view(), &d_beta)
3947 .expect("zero direction must be valid");
3948 for &v in jet.iter() {
3949 assert!(v.abs() < 1.0e-14, "expected zero kernel, got {v}");
3950 }
3951 }
3952
3953 #[test]
3954 fn beta_flat_dim_equals_active_classes_times_p() {
3955 let family = toy_family(3, 5, 4);
3956 assert_eq!(family.beta_flat_dim(), 3 * 5);
3957 }
3958
3959 #[test]
3960 fn matrix_free_matvec_matches_dense_hessian_dot() {
3961 let family = toy_family(7, 3, 4);
3965 let p = family.design.ncols();
3966 let m = family.active_classes();
3967 let n = family.weights.len();
3968 let design = family.design.view();
3969 let block_states: Vec<ParameterBlockState> = (0..m)
3971 .map(|a| {
3972 let beta =
3973 Array1::<f64>::from_shape_fn(p, |i| 0.3 * ((a + 1) as f64) - 0.1 * (i as f64));
3974 let eta = Array1::<f64>::from_shape_fn(n, |row| {
3975 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
3976 });
3977 ParameterBlockState { beta, eta }
3978 })
3979 .collect();
3980 let specs = family.build_block_specs();
3981 let ws = family
3982 .exact_newton_joint_hessian_workspace(&block_states, &specs)
3983 .expect("workspace build must succeed")
3984 .expect("workspace must be present");
3985 let dense = family
3986 .exact_newton_joint_hessian(&block_states)
3987 .expect("dense Hessian must build")
3988 .expect("dense Hessian must be present");
3989 for seed in 0..(m * p) {
3991 let v = Array1::<f64>::from_shape_fn(m * p, |i| {
3992 if i == seed {
3993 1.0
3994 } else {
3995 0.07 * ((i + 1) as f64).cos()
3996 }
3997 });
3998 let mf = ws
3999 .hessian_matvec(&v)
4000 .expect("matvec must succeed")
4001 .expect("matvec must be present");
4002 let dv = dense.dot(&v);
4003 for (a, b) in mf.iter().zip(dv.iter()) {
4004 assert!(
4005 (a - b).abs() < 1.0e-9,
4006 "matrix-free matvec {a} != dense {b}"
4007 );
4008 }
4009 let mut into = Array1::<f64>::from_elem(m * p, f64::NAN);
4011 let wrote = ws
4012 .hessian_matvec_into(&v, &mut into)
4013 .expect("matvec_into must succeed");
4014 assert!(wrote, "matvec_into must report it wrote");
4015 for (a, b) in into.iter().zip(mf.iter()) {
4016 assert!((a - b).abs() < 1.0e-12, "matvec_into {a} != matvec {b}");
4017 }
4018 }
4019 let mf_diag = ws
4021 .hessian_diagonal()
4022 .expect("diagonal must succeed")
4023 .expect("diagonal must be present");
4024 let dense_diag = dense.diag();
4025 for (a, b) in mf_diag.iter().zip(dense_diag.iter()) {
4026 assert!((a - b).abs() < 1.0e-9, "matrix-free diag {a} != dense {b}");
4027 }
4028 }
4029
4030 #[test]
4031 fn batched_second_directional_all_axes_matches_per_axis() {
4032 let family = toy_family(9, 3, 4);
4037 let p = family.design.ncols();
4038 let m = family.active_classes();
4039 let n = family.weights.len();
4040 let design = family.design.view();
4041 let block_states: Vec<ParameterBlockState> = (0..m)
4042 .map(|a| {
4043 let beta = Array1::<f64>::from_shape_fn(p, |i| {
4044 0.25 * ((a + 1) as f64) - 0.13 * (i as f64)
4045 });
4046 let eta = Array1::<f64>::from_shape_fn(n, |row| {
4047 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
4048 });
4049 ParameterBlockState { beta, eta }
4050 })
4051 .collect();
4052 let specs = family.build_block_specs();
4053 let dim = m * p;
4054
4055 let delta = Array1::<f64>::from_shape_fn(dim, |i| {
4057 0.4 - 0.07 * (i as f64) + 0.03 * ((i * i) as f64).cos()
4058 });
4059
4060 let batched = family
4062 .joint_jeffreys_information_second_directional_all_axes_with_specs(
4063 &block_states,
4064 &specs,
4065 &delta,
4066 )
4067 .expect("batched second-directional must succeed")
4068 .expect("batched second-directional must be present");
4069 assert_eq!(batched.len(), dim, "one matrix per canonical axis");
4070
4071 for axis in 0..dim {
4073 let mut e_a = Array1::<f64>::zeros(dim);
4074 e_a[axis] = 1.0;
4075 let per_axis = family
4076 .exact_newton_joint_hessiansecond_directional_derivative(
4077 &block_states,
4078 &delta,
4079 &e_a,
4080 )
4081 .expect("per-axis second-directional must succeed")
4082 .expect("per-axis second-directional must be present");
4083 assert_eq!(batched[axis].dim(), (dim, dim));
4084 for r in 0..dim {
4085 for c in 0..dim {
4086 let a = batched[axis][[r, c]];
4087 let b = per_axis[[r, c]];
4088 assert!(
4089 (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
4090 "axis {axis} entry ({r},{c}): batched {a} != per-axis {b}"
4091 );
4092 }
4093 }
4094 }
4095 }
4096
4097 #[test]
4098 fn batched_general_directional_derivatives_match_per_direction() {
4099 let family = toy_family(11, 4, 3);
4104 let p = family.design.ncols();
4105 let m = family.active_classes();
4106 let n = family.weights.len();
4107 let dim = m * p;
4108 let design = family.design.view();
4109 let block_states: Vec<ParameterBlockState> = (0..m)
4110 .map(|a| {
4111 let beta = Array1::<f64>::from_shape_fn(p, |i| {
4112 0.18 * ((a + 2) as f64) + 0.09 * ((i + 1) as f64).sin()
4113 });
4114 let eta = Array1::<f64>::from_shape_fn(n, |row| {
4115 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
4116 });
4117 ParameterBlockState { beta, eta }
4118 })
4119 .collect();
4120 let eta = family
4121 .collect_eta_matrix(&block_states)
4122 .expect("eta collection must succeed");
4123 let directions: Vec<Array1<f64>> = (0..5)
4124 .map(|seed| {
4125 Array1::<f64>::from_shape_fn(dim, |idx| {
4126 0.31 * ((seed + 1 + idx) as f64).sin()
4127 - 0.07 * ((seed * 3 + idx + 2) as f64).cos()
4128 })
4129 })
4130 .collect();
4131
4132 let batched = family
4133 .assemble_directional_derivatives(eta.view(), &directions)
4134 .expect("batched first directional derivatives must succeed");
4135 assert_eq!(batched.len(), directions.len());
4136 for (dir_idx, direction) in directions.iter().enumerate() {
4137 let per_direction = family
4138 .exact_newton_joint_hessian_directional_derivative(&block_states, direction)
4139 .expect("per-direction derivative must succeed")
4140 .expect("per-direction derivative must be present");
4141 for r in 0..dim {
4142 for c in 0..dim {
4143 let a = batched[dir_idx][[r, c]];
4144 let b = per_direction[[r, c]];
4145 assert!(
4146 (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
4147 "direction {dir_idx} entry ({r},{c}): batched {a} != per-direction {b}"
4148 );
4149 }
4150 }
4151 }
4152
4153 let specs = family.build_block_specs();
4154 let workspace = family
4155 .exact_newton_joint_hessian_workspace(&block_states, &specs)
4156 .expect("workspace build must succeed")
4157 .expect("workspace must be present");
4158 let operators = workspace
4159 .directional_derivative_operators(&directions)
4160 .expect("workspace batched operators must succeed");
4161 assert_eq!(operators.len(), directions.len());
4162 for (dir_idx, maybe_operator) in operators.into_iter().enumerate() {
4163 let dense = maybe_operator
4164 .expect("workspace must return a derivative operator")
4165 .to_dense();
4166 for r in 0..dim {
4167 for c in 0..dim {
4168 let a = dense[[r, c]];
4169 let b = batched[dir_idx][[r, c]];
4170 assert!(
4171 (a - b).abs() <= 1e-12 * (1.0 + b.abs()),
4172 "operator direction {dir_idx} entry ({r},{c}): {a} != {b}"
4173 );
4174 }
4175 }
4176 }
4177 }
4178
4179 #[test]
4180 fn workspace_batched_second_directional_pairs_match_per_pair() {
4181 let family = toy_family(10, 4, 4);
4186 let p = family.design.ncols();
4187 let m = family.active_classes();
4188 let n = family.weights.len();
4189 let dim = m * p;
4190 let design = family.design.view();
4191 let block_states: Vec<ParameterBlockState> = (0..m)
4192 .map(|a| {
4193 let beta = Array1::<f64>::from_shape_fn(p, |i| {
4194 0.11 * ((a + 3) as f64) - 0.06 * ((i + 2) as f64).cos()
4195 });
4196 let eta = Array1::<f64>::from_shape_fn(n, |row| {
4197 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
4198 });
4199 ParameterBlockState { beta, eta }
4200 })
4201 .collect();
4202 let specs = family.build_block_specs();
4203 let workspace = family
4204 .exact_newton_joint_hessian_workspace(&block_states, &specs)
4205 .expect("workspace build must succeed")
4206 .expect("workspace must be present");
4207 let pairs: Vec<(Array1<f64>, Array1<f64>)> = (0..7)
4208 .map(|seed| {
4209 let u = Array1::<f64>::from_shape_fn(dim, |idx| {
4210 0.19 * ((seed + idx + 1) as f64).sin()
4211 + 0.05 * ((2 * seed + idx + 3) as f64).cos()
4212 });
4213 let v = Array1::<f64>::from_shape_fn(dim, |idx| {
4214 -0.17 * ((seed + 2 * idx + 5) as f64).cos()
4215 + 0.04 * ((seed + idx + 7) as f64).sin()
4216 });
4217 (u, v)
4218 })
4219 .collect();
4220
4221 let batched = workspace
4222 .second_directional_derivative_operators(&pairs)
4223 .expect("workspace batched second-directional operators must succeed");
4224 assert_eq!(batched.len(), pairs.len());
4225
4226 for (pair_idx, ((u, v), maybe_operator)) in
4227 pairs.iter().zip(batched.into_iter()).enumerate()
4228 {
4229 let dense = maybe_operator
4230 .expect("workspace must return a second-directional operator")
4231 .to_dense();
4232 let per_pair = family
4233 .exact_newton_joint_hessiansecond_directional_derivative(&block_states, u, v)
4234 .expect("per-pair second-directional must succeed")
4235 .expect("per-pair second-directional must be present");
4236 for r in 0..dim {
4237 for c in 0..dim {
4238 let a = dense[[r, c]];
4239 let b = per_pair[[r, c]];
4240 assert!(
4241 (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
4242 "pair {pair_idx} entry ({r},{c}): batched {a} != per-pair {b}"
4243 );
4244 }
4245 }
4246 }
4247 }
4248
4249 #[test]
4258 fn matrix_free_directional_operator_matches_dense_oracle() {
4259 for &(n, p, k, rank) in &[(11, 4, 3, 2), (9, 5, 4, 3), (13, 3, 5, 4), (7, 6, 3, 1)] {
4262 let family = toy_family(n, p, k);
4263 let m = family.active_classes();
4264 let dim = m * p;
4265 let design = family.design.view();
4266 let block_states: Vec<ParameterBlockState> = (0..m)
4267 .map(|a| {
4268 let beta = Array1::<f64>::from_shape_fn(p, |i| {
4269 0.13 * ((a + 2) as f64) - 0.08 * ((i + 1) as f64).cos()
4270 });
4271 let eta = Array1::<f64>::from_shape_fn(n, |row| {
4272 (0..p).map(|i| design[[row, i]] * beta[i]).sum()
4273 });
4274 ParameterBlockState { beta, eta }
4275 })
4276 .collect();
4277 let eta = family
4278 .collect_eta_matrix(&block_states)
4279 .expect("eta collection must succeed");
4280 let probs = family.row_probabilities(eta.view());
4281
4282 let factor = Array2::<f64>::from_shape_fn((dim, rank), |(r, c)| {
4284 0.41 * ((r + 2 * c + 1) as f64).sin() - 0.12 * ((3 * r + c + 2) as f64).cos()
4285 });
4286 let probe = Array1::<f64>::from_shape_fn(dim, |idx| {
4287 0.27 * ((idx + 1) as f64).sin() + 0.05 * ((idx + 3) as f64).cos()
4288 });
4289
4290 let directions: Vec<Array1<f64>> = (0..4)
4291 .map(|seed| {
4292 Array1::<f64>::from_shape_fn(dim, |idx| {
4293 0.29 * ((seed + idx + 1) as f64).sin()
4294 - 0.06 * ((2 * seed + idx + 2) as f64).cos()
4295 })
4296 })
4297 .collect();
4298
4299 let dense_mats = family
4301 .assemble_directional_derivatives_from_probs(probs.view(), &directions)
4302 .expect("dense directional assembly must succeed");
4303 for (idx, direction) in directions.iter().enumerate() {
4304 let dense = DenseMatrixHyperOperator {
4305 matrix: dense_mats[idx].clone(),
4306 };
4307 let mf = family
4308 .directional_hyper_operator(probs.view(), direction)
4309 .expect("matrix-free directional operator must build");
4310 assert_oracle_parity(
4311 &dense,
4312 &mf,
4313 &factor,
4314 &probe,
4315 &format!("dir {idx} n={n} p={p} k={k}"),
4316 );
4317 }
4318
4319 let pairs: Vec<(Array1<f64>, Array1<f64>)> = (0..3)
4321 .map(|seed| {
4322 let u = Array1::<f64>::from_shape_fn(dim, |idx| {
4323 0.21 * ((seed + idx + 1) as f64).sin()
4324 });
4325 let v = Array1::<f64>::from_shape_fn(dim, |idx| {
4326 -0.18 * ((seed + 2 * idx + 4) as f64).cos()
4327 });
4328 (u, v)
4329 })
4330 .collect();
4331 let dense_pairs = family
4332 .assemble_second_directional_derivatives_from_probs(probs.view(), &pairs)
4333 .expect("dense second-directional assembly must succeed");
4334 for (idx, (u, v)) in pairs.iter().enumerate() {
4335 let dense = DenseMatrixHyperOperator {
4336 matrix: dense_pairs[idx].clone(),
4337 };
4338 let mf = family
4339 .second_directional_hyper_operator(probs.view(), u, v)
4340 .expect("matrix-free second-directional operator must build");
4341 assert_oracle_parity(
4342 &dense,
4343 &mf,
4344 &factor,
4345 &probe,
4346 &format!("pair {idx} n={n} p={p} k={k}"),
4347 );
4348 }
4349 }
4350 }
4351
4352 fn assert_oracle_parity(
4354 dense: &DenseMatrixHyperOperator,
4355 mf: &MultinomialDirectionalHyperOperator,
4356 factor: &Array2<f64>,
4357 probe: &Array1<f64>,
4358 ctx: &str,
4359 ) {
4360 assert_eq!(dense.dim(), mf.dim(), "{ctx}: dim mismatch");
4361
4362 let pd = dense.projected_matrix(factor);
4364 let pm = mf.projected_matrix(factor);
4365 for ((r, c), &a) in pd.indexed_iter() {
4366 let b = pm[[r, c]];
4367 assert!(
4368 (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
4369 "{ctx}: projected_matrix[{r},{c}] dense {a} != matrix-free {b}"
4370 );
4371 }
4372
4373 let td = dense.trace_projected_factor(factor);
4375 let tm = mf.trace_projected_factor(factor);
4376 assert!(
4377 (td - tm).abs() <= 1e-10 * (1.0 + td.abs()),
4378 "{ctx}: trace dense {td} != matrix-free {tm}"
4379 );
4380
4381 let bvd = dense.mul_vec(probe);
4383 let bvm = mf.mul_vec(probe);
4384 for (idx, (&a, &b)) in bvd.iter().zip(bvm.iter()).enumerate() {
4385 assert!(
4386 (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
4387 "{ctx}: mul_vec[{idx}] dense {a} != matrix-free {b}"
4388 );
4389 }
4390
4391 let dd = dense.to_dense();
4393 let dm = mf.to_dense();
4394 for ((r, c), &a) in dd.indexed_iter() {
4395 let b = dm[[r, c]];
4396 assert!(
4397 (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
4398 "{ctx}: to_dense[{r},{c}] dense {a} != matrix-free {b}"
4399 );
4400 }
4401 }
4402
4403 #[test]
4404 fn new_rejects_k_less_than_two() {
4405 let n = 3;
4406 let y = array![[1.0], [1.0], [1.0]];
4407 let w = Array1::<f64>::ones(n);
4408 let x = Arc::new(Array2::<f64>::ones((n, 1)));
4409 let zero = Array2::<f64>::zeros((1, 1));
4410 let s = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(zero)]);
4411 let err = MultinomialFamily::new(y, w, 1, x, s).expect_err("K = 1 must be rejected");
4412 assert!(err.contains("K"));
4413 }
4414
4415 fn family_with_weights(
4432 n_obs: usize,
4433 p: usize,
4434 k: usize,
4435 weights: Array1<f64>,
4436 ) -> MultinomialFamily {
4437 let y = {
4438 let mut y = Array2::<f64>::zeros((n_obs, k));
4439 for i in 0..n_obs {
4440 y[[i, (3 * i + 1) % k]] = 1.0;
4441 }
4442 y
4443 };
4444 let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
4445 0.7 * ((i as f64 + 1.0) * 0.31 + (j as f64) * 0.53).sin() - 0.2 * (j as f64)
4446 }));
4447 let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(
4448 Array2::<f64>::from_shape_fn((p, p), |(i, j)| if i == j { 1.0 } else { 0.0 }),
4449 )]);
4450 MultinomialFamily::new(y, weights, k, design, penalties)
4451 .expect("family_with_weights must construct")
4452 }
4453
4454 fn states_at_betas(
4457 family: &MultinomialFamily,
4458 betas: &[Array1<f64>],
4459 ) -> Vec<ParameterBlockState> {
4460 let x = family.design.view();
4461 betas
4462 .iter()
4463 .map(|b| ParameterBlockState {
4464 beta: b.clone(),
4465 eta: x.dot(b),
4466 })
4467 .collect()
4468 }
4469
4470 fn sample_betas(m: usize, p: usize, scale: f64) -> Vec<Array1<f64>> {
4472 (0..m)
4473 .map(|a| {
4474 Array1::from_shape_fn(p, |i| {
4475 scale * (0.41 * (a as f64 + 1.0) - 0.23 * (i as f64) + 0.13).sin()
4476 })
4477 })
4478 .collect()
4479 }
4480
4481 fn neglogl_grad(family: &MultinomialFamily, states: &[ParameterBlockState]) -> Array1<f64> {
4485 let eta = family.collect_eta_matrix(states).expect("eta collect");
4486 let probs = family.row_probabilities(eta.view());
4487 let x = family.design.view();
4488 let n = family.weights.len();
4489 let p = family.design.ncols();
4490 let m = family.active_classes();
4491 let mut g = Array1::<f64>::zeros(m * p);
4492 for a in 0..m {
4493 for i in 0..p {
4494 let mut acc = 0.0_f64;
4495 for row in 0..n {
4496 acc += x[[row, i]]
4497 * family.weights[row]
4498 * (probs[[row, a]] - family.y_one_hot[[row, a]]);
4499 }
4500 g[a * p + i] = acc;
4501 }
4502 }
4503 g
4504 }
4505
4506 fn perturb(betas: &[Array1<f64>], v: &Array1<f64>, factor: f64) -> Vec<Array1<f64>> {
4507 let p = betas[0].len();
4508 betas
4509 .iter()
4510 .enumerate()
4511 .map(|(a, b)| Array1::from_shape_fn(p, |i| b[i] + factor * v[a * p + i]))
4512 .collect()
4513 }
4514
4515 #[test]
4516 fn matrix_free_matvec_matches_dense_across_directions() {
4517 let n = 13;
4519 let p = 4;
4520 let k = 4;
4521 let family = family_with_weights(
4522 n,
4523 p,
4524 k,
4525 Array1::from_shape_fn(n, |i| 0.5 + 0.5 * ((i as f64) * 0.37).cos().abs()),
4526 );
4527 let m = family.active_classes();
4528 let total = m * p;
4529 let states = states_at_betas(&family, &sample_betas(m, p, 0.8));
4530 let specs = family.build_block_specs();
4531 let ws = family
4532 .exact_newton_joint_hessian_workspace(&states, &specs)
4533 .expect("workspace build")
4534 .expect("workspace present");
4535 let dense = ws.hessian_dense().expect("dense").expect("dense present");
4536
4537 for seed in 0..8usize {
4538 let v = Array1::from_shape_fn(total, |idx| {
4539 ((seed * 31 + idx * 17 + 5) as f64 * 0.123).cos()
4540 });
4541 let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4542 let dv = dense.dot(&v);
4543 let mut max_abs = 0.0_f64;
4544 let mut scale = 1.0e-300_f64;
4545 for idx in 0..total {
4546 max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
4547 scale = scale.max(dv[idx].abs());
4548 }
4549 assert!(
4550 max_abs <= 1.0e-10 * scale + 1.0e-13,
4551 "seed {seed}: matrix-free matvec deviates from dense by {max_abs} (scale {scale})"
4552 );
4553 }
4554 }
4555
4556 #[test]
4557 fn matrix_free_matvec_does_not_allocate_dense_but_matches_at_extreme_eta() {
4558 let n = 9;
4562 let p = 3;
4563 let k = 5;
4564 let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
4565 let m = family.active_classes();
4566 let total = m * p;
4567 let states = states_at_betas(&family, &sample_betas(m, p, 12.0));
4568 let specs = family.build_block_specs();
4569 let ws = family
4570 .exact_newton_joint_hessian_workspace(&states, &specs)
4571 .expect("workspace build")
4572 .expect("workspace present");
4573 let dense = ws.hessian_dense().expect("dense").expect("dense present");
4574 let v = Array1::from_shape_fn(total, |idx| ((idx as f64) * 0.91 - 1.0).sin());
4575 let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4576 let dv = dense.dot(&v);
4577 let mut max_abs = 0.0_f64;
4578 let mut scale = 1.0e-300_f64;
4579 for idx in 0..total {
4580 assert!(mf[idx].is_finite(), "matvec entry {idx} not finite");
4581 max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
4582 scale = scale.max(dv[idx].abs());
4583 }
4584 assert!(
4585 max_abs <= 1.0e-10 * scale + 1.0e-13,
4586 "extreme-η matvec deviates from dense by {max_abs} (scale {scale})"
4587 );
4588 }
4589
4590 #[test]
4591 fn matrix_free_matvec_handles_zero_weight_rows() {
4592 let n = 10;
4594 let p = 3;
4595 let k = 3;
4596 let mut w = Array1::<f64>::ones(n);
4597 w[2] = 0.0;
4598 w[5] = 0.0;
4599 w[9] = 0.0;
4600 let family = family_with_weights(n, p, k, w);
4601 let m = family.active_classes();
4602 let total = m * p;
4603 let states = states_at_betas(&family, &sample_betas(m, p, 0.6));
4604 let specs = family.build_block_specs();
4605 let ws = family
4606 .exact_newton_joint_hessian_workspace(&states, &specs)
4607 .expect("workspace build")
4608 .expect("workspace present");
4609 let dense = ws.hessian_dense().expect("dense").expect("dense present");
4610 let v = Array1::from_shape_fn(total, |idx| (idx as f64 + 0.5).cos());
4611 let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4612 let dv = dense.dot(&v);
4613 let mut max_abs = 0.0_f64;
4614 let mut scale = 1.0e-300_f64;
4615 for idx in 0..total {
4616 max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
4617 scale = scale.max(dv[idx].abs());
4618 }
4619 assert!(
4620 max_abs <= 1.0e-10 * scale + 1.0e-13,
4621 "zero-weight matvec deviates from dense by {max_abs} (scale {scale})"
4622 );
4623 }
4624
4625 #[test]
4626 fn workspace_gradient_and_loglik_match_family_evaluation_and_prefer_operator() {
4627 let n = 11;
4635 let p = 4;
4636 let k = 3;
4637 let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
4638 let m = family.active_classes();
4639 let states = states_at_betas(&family, &sample_betas(m, p, 0.9));
4640 let specs = family.build_block_specs();
4641
4642 let family_eval = family
4643 .exact_newton_joint_gradient_evaluation(&states, &specs)
4644 .expect("family joint gradient eval")
4645 .expect("family joint gradient present");
4646
4647 let ws = family
4648 .exact_newton_joint_hessian_workspace(&states, &specs)
4649 .expect("workspace build")
4650 .expect("workspace present");
4651
4652 assert_eq!(
4653 ws.hessian_source_preference(),
4654 JointHessianSourcePreference::Operator,
4655 "multinomial workspace must prefer the operator (matrix-free) source"
4656 );
4657
4658 let ws_loglik = ws
4659 .joint_log_likelihood_evaluation()
4660 .expect("workspace loglik")
4661 .expect("workspace loglik present");
4662 assert!(
4663 (ws_loglik - family_eval.log_likelihood).abs()
4664 <= 1e-12 * (1.0 + family_eval.log_likelihood.abs()),
4665 "workspace loglik {ws_loglik} != family loglik {}",
4666 family_eval.log_likelihood
4667 );
4668
4669 let ws_grad_eval = ws
4670 .joint_gradient_evaluation()
4671 .expect("workspace gradient eval")
4672 .expect("workspace gradient present");
4673 assert!(
4674 (ws_grad_eval.log_likelihood - family_eval.log_likelihood).abs()
4675 <= 1e-12 * (1.0 + family_eval.log_likelihood.abs()),
4676 "workspace gradient-eval loglik mismatch"
4677 );
4678 assert_eq!(ws_grad_eval.gradient.len(), family_eval.gradient.len());
4679 let mut max_abs = 0.0_f64;
4680 let mut scale = 1.0e-300_f64;
4681 for idx in 0..family_eval.gradient.len() {
4682 max_abs = max_abs.max((ws_grad_eval.gradient[idx] - family_eval.gradient[idx]).abs());
4683 scale = scale.max(family_eval.gradient[idx].abs());
4684 }
4685 assert!(
4686 max_abs <= 1e-10 * scale + 1e-13,
4687 "workspace gradient deviates from family gradient by {max_abs} (scale {scale})"
4688 );
4689 }
4690
4691 #[test]
4692 fn matrix_free_matvec_binary_k_equals_two() {
4693 let n = 7;
4696 let p = 3;
4697 let k = 2;
4698 let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
4699 let m = family.active_classes();
4700 assert_eq!(m, 1);
4701 let total = m * p;
4702 let states = states_at_betas(&family, &sample_betas(m, p, 1.1));
4703 let specs = family.build_block_specs();
4704 let ws = family
4705 .exact_newton_joint_hessian_workspace(&states, &specs)
4706 .expect("workspace build")
4707 .expect("workspace present");
4708 let dense = ws.hessian_dense().expect("dense").expect("dense present");
4709 let v = Array1::from_shape_fn(total, |idx| (idx as f64 * 0.7 + 0.2).sin());
4710 let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4711 let dv = dense.dot(&v);
4712 for idx in 0..total {
4713 assert!(
4714 (mf[idx] - dv[idx]).abs() <= 1.0e-12 * (1.0 + dv[idx].abs()),
4715 "binary matvec entry {idx}: {} vs {}",
4716 mf[idx],
4717 dv[idx]
4718 );
4719 }
4720 }
4721
4722 #[test]
4723 fn matrix_free_matvec_into_matches_owned_return() {
4724 let n = 8;
4725 let p = 3;
4726 let k = 4;
4727 let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
4728 let m = family.active_classes();
4729 let total = m * p;
4730 let states = states_at_betas(&family, &sample_betas(m, p, 0.9));
4731 let specs = family.build_block_specs();
4732 let ws = family
4733 .exact_newton_joint_hessian_workspace(&states, &specs)
4734 .expect("workspace build")
4735 .expect("workspace present");
4736 let v = Array1::from_shape_fn(total, |idx| (idx as f64 * 1.7 - 0.3).cos());
4737 let owned = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4738 let mut out = Array1::from_elem(total, 7.0_f64);
4740 let wrote = ws.hessian_matvec_into(&v, &mut out).expect("matvec_into");
4741 assert!(wrote, "matvec_into must report it wrote a result");
4742 assert_eq!(out, owned, "into-variant must match owned return bitwise");
4743 }
4744
4745 #[test]
4746 fn matrix_free_diagonal_is_bit_identical_to_dense_diag() {
4747 let n = 11;
4748 let p = 4;
4749 let k = 4;
4750 let family = family_with_weights(
4751 n,
4752 p,
4753 k,
4754 Array1::from_shape_fn(n, |i| 0.25 + (i as f64 % 3.0)),
4755 );
4756 let m = family.active_classes();
4757 let total = m * p;
4758 let states = states_at_betas(&family, &sample_betas(m, p, 0.7));
4759 let specs = family.build_block_specs();
4760 let ws = family
4761 .exact_newton_joint_hessian_workspace(&states, &specs)
4762 .expect("workspace build")
4763 .expect("workspace present");
4764 let dense = ws.hessian_dense().expect("dense").expect("dense present");
4765 let diag = ws
4766 .hessian_diagonal()
4767 .expect("diagonal")
4768 .expect("diagonal some");
4769 for idx in 0..total {
4770 let got = diag[idx];
4778 let expected = dense[[idx, idx]];
4779 let tol = 1e-12 * (1.0 + expected.abs());
4780 assert!(
4781 (got - expected).abs() <= tol,
4782 "matrix-free diagonal entry {idx} must equal dense diagonal to a few ULP: \
4783 got={got} dense={expected} (tol={tol})"
4784 );
4785 }
4786 }
4787
4788 #[test]
4789 fn matrix_free_matvec_matches_gradient_finite_difference() {
4790 let n = 12;
4795 let p = 3;
4796 let k = 4;
4797 let family = family_with_weights(
4798 n,
4799 p,
4800 k,
4801 Array1::from_shape_fn(n, |i| 0.4 + 0.3 * ((i as f64) * 0.6).sin().abs()),
4802 );
4803 let m = family.active_classes();
4804 let total = m * p;
4805 let betas = sample_betas(m, p, 0.5);
4806 let states = states_at_betas(&family, &betas);
4807 let specs = family.build_block_specs();
4808 let ws = family
4809 .exact_newton_joint_hessian_workspace(&states, &specs)
4810 .expect("workspace build")
4811 .expect("workspace present");
4812
4813 let v = Array1::from_shape_fn(total, |idx| 0.5 * ((idx as f64 * 1.3 + 0.7).sin()));
4814 let hv = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4815
4816 let eps = 1.0e-6;
4817 let g_plus = neglogl_grad(
4818 &family,
4819 &states_at_betas(&family, &perturb(&betas, &v, eps)),
4820 );
4821 let g_minus = neglogl_grad(
4822 &family,
4823 &states_at_betas(&family, &perturb(&betas, &v, -eps)),
4824 );
4825 let mut max_abs = 0.0_f64;
4826 let mut scale = 1.0e-300_f64;
4827 for idx in 0..total {
4828 let fd = (g_plus[idx] - g_minus[idx]) / (2.0 * eps);
4829 max_abs = max_abs.max((hv[idx] - fd).abs());
4830 scale = scale.max(fd.abs());
4831 }
4832 assert!(
4833 max_abs <= 1.0e-5 * scale + 1.0e-7,
4834 "matvec vs gradient finite-difference deviates by {max_abs} (scale {scale})"
4835 );
4836 }
4837
4838 fn perturb_axis(
4869 family: &MultinomialFamily,
4870 betas: &[Array1<f64>],
4871 a0: usize,
4872 i0: usize,
4873 factor: f64,
4874 ) -> Vec<ParameterBlockState> {
4875 let mut shifted = betas.to_vec();
4876 shifted[a0][i0] += factor;
4877 states_at_betas(family, &shifted)
4878 }
4879
4880 #[test]
4881 fn all_axis_directional_derivatives_match_static_hessian_finite_difference() {
4882 let n = 11;
4885 let p = 3;
4886 let k = 4;
4887 let family = family_with_weights(
4888 n,
4889 p,
4890 k,
4891 Array1::from_shape_fn(n, |i| 0.5 + 0.4 * ((i as f64) * 0.41).sin().abs()),
4892 );
4893 let m = family.active_classes();
4894 let total = m * p;
4895 let betas = sample_betas(m, p, 0.6);
4896 let states = states_at_betas(&family, &betas);
4897 let eta = family.collect_eta_matrix(&states).expect("eta collect");
4898
4899 let hand = family.assemble_all_axis_directional_derivatives(eta.view());
4900 assert_eq!(
4901 hand.len(),
4902 total,
4903 "one directional matrix per canonical axis"
4904 );
4905
4906 let eps = 1.0e-6;
4907 let mut max_rel = 0.0_f64;
4908 for a0 in 0..m {
4909 for i0 in 0..p {
4910 let axis = a0 * p + i0;
4911 let h_plus = family
4912 .exact_newton_joint_hessian(&perturb_axis(&family, &betas, a0, i0, eps))
4913 .expect("H+")
4914 .expect("H+ some");
4915 let h_minus = family
4916 .exact_newton_joint_hessian(&perturb_axis(&family, &betas, a0, i0, -eps))
4917 .expect("H-")
4918 .expect("H- some");
4919 let hand_axis = &hand[axis];
4920 for r in 0..total {
4921 for c in 0..total {
4922 let fd = (h_plus[[r, c]] - h_minus[[r, c]]) / (2.0 * eps);
4923 let scale = fd.abs().max(hand_axis[[r, c]].abs()).max(1.0);
4924 max_rel = max_rel.max((hand_axis[[r, c]] - fd).abs() / scale);
4925 }
4926 }
4927 }
4928 }
4929 assert!(
4930 max_rel <= 1.0e-6,
4931 "softmax all-axis directional assembly drifted from the static-Hessian \
4932 finite difference by relative {max_rel:.3e}"
4933 );
4934 }
4935
4936 #[test]
4937 fn all_axis_second_directional_derivatives_match_directional_finite_difference() {
4938 let n = 10;
4939 let p = 3;
4940 let k = 4;
4941 let family = family_with_weights(
4942 n,
4943 p,
4944 k,
4945 Array1::from_shape_fn(n, |i| 0.6 + 0.3 * ((i as f64) * 0.53).cos().abs()),
4946 );
4947 let m = family.active_classes();
4948 let total = m * p;
4949 let betas = sample_betas(m, p, 0.5);
4950 let states = states_at_betas(&family, &betas);
4951 let eta = family.collect_eta_matrix(&states).expect("eta collect");
4952
4953 let delta = Array1::from_shape_fn(total, |idx| 0.4 * ((idx as f64 * 1.7 + 0.3).sin()));
4956
4957 let hand = family
4958 .assemble_all_axis_second_directional_derivatives(eta.view(), &delta)
4959 .expect("second-directional assembly");
4960 assert_eq!(hand.len(), total, "one second-directional matrix per axis");
4961
4962 let hdot_at = |st: &[ParameterBlockState]| -> Array2<f64> {
4966 family
4967 .exact_newton_joint_hessian_directional_derivative(st, &delta)
4968 .expect("Hdot")
4969 .expect("Hdot some")
4970 };
4971
4972 let eps = 1.0e-6;
4973 let mut max_rel = 0.0_f64;
4974 for a0 in 0..m {
4975 for i0 in 0..p {
4976 let axis = a0 * p + i0;
4977 let hd_plus = hdot_at(&perturb_axis(&family, &betas, a0, i0, eps));
4978 let hd_minus = hdot_at(&perturb_axis(&family, &betas, a0, i0, -eps));
4979 let hand_axis = &hand[axis];
4980 for r in 0..total {
4981 for c in 0..total {
4982 let fd = (hd_plus[[r, c]] - hd_minus[[r, c]]) / (2.0 * eps);
4983 let scale = fd.abs().max(hand_axis[[r, c]].abs()).max(1.0);
4984 max_rel = max_rel.max((hand_axis[[r, c]] - fd).abs() / scale);
4985 }
4986 }
4987 }
4988 }
4989 assert!(
4990 max_rel <= 1.0e-5,
4991 "softmax all-axis second-directional assembly drifted from the directional \
4992 finite difference by relative {max_rel:.3e}"
4993 );
4994 }
4995
4996 #[test]
5024 fn separating_multinomial_arms_universal_jeffreys_firth_term() {
5025 use gam_linalg::faer_ndarray::FaerEigh;
5026 use gam_solve::estimate::reml::jeffreys_subspace::{
5027 jeffreys_subspace_from_penalty, joint_jeffreys_term,
5028 };
5029
5030 let n = 60usize;
5034 let k = 3usize;
5035 let p = 2usize; let design = Arc::new(Array2::<f64>::from_shape_fn(
5037 (n, p),
5038 |(row, col)| match col {
5039 0 => 1.0,
5040 _ => -3.0 + 6.0 * (row as f64) / ((n - 1) as f64),
5041 },
5042 ));
5043 let mut y = Array2::<f64>::zeros((n, k));
5044 for row in 0..n {
5045 let x = design[[row, 1]];
5046 let class = if x < -1.0 {
5047 0
5048 } else if x > 1.0 {
5049 1
5050 } else {
5051 2 };
5053 y[[row, class]] = 1.0;
5054 }
5055 let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(Array2::<
5058 f64,
5059 >::zeros(
5060 (
5061 p, p,
5062 )
5063 ))]);
5064 let weights = Array1::<f64>::ones(n);
5065 let family = MultinomialFamily::new(y, weights, k, design, penalties)
5066 .expect("separated multinomial family must construct");
5067
5068 let m = family.active_classes();
5069 let total = m * p;
5070
5071 let betas: Vec<Array1<f64>> = (0..m)
5075 .map(|a| Array1::from_vec(vec![-300.0, 600.0 * ((a as f64) - 0.5)]))
5076 .collect();
5077 let states = states_at_betas(&family, &betas);
5078
5079 let h_joint = family
5082 .exact_newton_joint_hessian(&states)
5083 .expect("joint Hessian eval")
5084 .expect("multinomial exposes an explicit joint Hessian");
5085 assert_eq!(h_joint.dim(), (total, total));
5086
5087 let (evals, _) = h_joint
5091 .eigh(faer::Side::Lower)
5092 .expect("information eigendecomposition");
5093 let lambda_max = evals.iter().cloned().fold(0.0_f64, f64::max);
5094 let lambda_min = evals.iter().cloned().fold(f64::INFINITY, f64::min);
5095 assert!(
5096 lambda_max > 0.0 && lambda_min / lambda_max < 1.0e-6,
5097 "fixture must be near-separating: λ_min/λ_max = {} (λ_min={lambda_min}, λ_max={lambda_max})",
5098 lambda_min / lambda_max
5099 );
5100
5101 let aggregate = Array2::<f64>::zeros((p, p));
5104 let block_span = jeffreys_subspace_from_penalty(aggregate.view())
5105 .expect("block Jeffreys span")
5106 .columns;
5107 assert_eq!(block_span.dim(), (p, p));
5108 let mut z_joint = Array2::<f64>::zeros((total, total));
5109 for b in 0..m {
5110 for i in 0..p {
5111 for j in 0..p {
5112 z_joint[[b * p + i, b * p + j]] = block_span[[i, j]];
5113 }
5114 }
5115 }
5116
5117 let (phi, grad_phi, hphi) =
5121 joint_jeffreys_term(h_joint.view(), z_joint.view(), |direction: &Array1<f64>| {
5122 family.exact_newton_joint_hessian_directional_derivative(&states, direction)
5123 })
5124 .expect("multinomial joint Jeffreys term must evaluate");
5125
5126 let term_active =
5129 phi != 0.0 || grad_phi.iter().any(|v| *v != 0.0) || hphi.iter().any(|v| *v != 0.0);
5130 assert!(
5131 term_active,
5132 "Jeffreys/Firth term must fire on a separating multinomial fit (φ={phi})"
5133 );
5134
5135 assert!(
5138 phi.is_finite() && grad_phi.iter().all(|v| v.is_finite()),
5139 "Jeffreys φ/∇φ must be finite (φ={phi})"
5140 );
5141 for v in hphi.iter() {
5142 assert!(v.is_finite(), "H_Φ entry must be finite, got {v}");
5143 }
5144
5145 let (_, evecs) = h_joint
5150 .eigh(faer::Side::Lower)
5151 .expect("eig for separating direction");
5152 let sep_dir = evecs.column(0).to_owned(); let curv_h = sep_dir.dot(&h_joint.dot(&sep_dir));
5154 let curv_hphi = sep_dir.dot(&hphi.dot(&sep_dir));
5155 assert!(
5156 curv_hphi > 0.0,
5157 "H_Φ must supply positive curvature on the separating direction (got {curv_hphi}; bare H curvature there is {curv_h})"
5158 );
5159 assert!(
5160 curv_hphi.is_finite() && curv_hphi >= curv_h,
5161 "augmented curvature {curv_hphi} must dominate the near-zero bare curvature {curv_h}"
5162 );
5163 }
5164
5165 fn second_difference_penalty(p: usize) -> Array2<f64> {
5169 let mut s = Array2::<f64>::zeros((p, p));
5170 for r in 0..p.saturating_sub(2) {
5171 let d = [1.0_f64, -2.0, 1.0];
5173 for (a, &da) in d.iter().enumerate() {
5174 for (b, &db) in d.iter().enumerate() {
5175 s[[r + a, r + b]] += da * db;
5176 }
5177 }
5178 }
5179 s
5180 }
5181
5182 #[test]
5189 fn centered_penalty_is_reference_class_invariant_1587() {
5190 let p = 5usize;
5191 let s = second_difference_penalty(p);
5192 let gamma: [Array1<f64>; 3] = [
5196 array![0.4, -0.1, 0.7, 0.2, -0.5],
5197 array![-0.3, 0.8, 0.1, -0.6, 0.25],
5198 array![0.15, 0.05, -0.4, 0.9, -0.2],
5199 ];
5200 let k = 3usize;
5201 let m = k - 1;
5202 let metric = centered_class_metric(m, k);
5203
5204 let centered_value = |r: usize| -> f64 {
5208 let actives: Vec<usize> = (0..3).filter(|&c| c != r).collect();
5209 let mut beta = Array1::<f64>::zeros(m * p);
5210 for (a, &cls) in actives.iter().enumerate() {
5211 let diff = &gamma[cls] - &gamma[r];
5212 beta.slice_mut(ndarray::s![a * p..(a + 1) * p])
5213 .assign(&diff);
5214 }
5215 let mut acc = 0.0;
5217 for a in 0..m {
5218 for b in 0..m {
5219 let ba = beta.slice(ndarray::s![a * p..(a + 1) * p]);
5220 let bb = beta.slice(ndarray::s![b * p..(b + 1) * p]);
5221 acc += metric[[a, b]] * ba.dot(&s.dot(&bb));
5222 }
5223 }
5224 acc
5225 };
5226 let diagonal_value = |r: usize| -> f64 {
5227 let actives: Vec<usize> = (0..3).filter(|&c| c != r).collect();
5228 actives
5229 .iter()
5230 .map(|&cls| {
5231 let diff = &gamma[cls] - &gamma[r];
5232 diff.dot(&s.dot(&diff))
5233 })
5234 .sum()
5235 };
5236
5237 let c0 = centered_value(0);
5238 let c1 = centered_value(1);
5239 let c2 = centered_value(2);
5240 assert!(
5241 (c0 - c1).abs() < 1e-12 && (c0 - c2).abs() < 1e-12,
5242 "centered penalty must be reference-invariant: {c0} {c1} {c2}"
5243 );
5244 let mean: Array1<f64> = (&gamma[0] + &gamma[1] + &gamma[2]) / 3.0;
5246 let clr: f64 = gamma
5247 .iter()
5248 .map(|g| {
5249 let c = g - &mean;
5250 c.dot(&s.dot(&c))
5251 })
5252 .sum();
5253 assert!(
5254 (c0 - clr).abs() < 1e-10,
5255 "centered penalty {c0} must equal the CLR form {clr}"
5256 );
5257
5258 let d0 = diagonal_value(0);
5260 let d1 = diagonal_value(1);
5261 let d2 = diagonal_value(2);
5262 let diag_spread = (d0 - d1).abs().max((d0 - d2).abs()).max((d1 - d2).abs());
5263 assert!(
5264 diag_spread > 1e-6,
5265 "reference-anchored penalty should differ across references (reproducing the bug); spread {diag_spread}"
5266 );
5267 }
5268
5269 #[test]
5272 fn centered_joint_penalty_spec_is_psd_with_declared_nullspace_1587() {
5273 use gam_linalg::faer_ndarray::FaerEigh;
5274 let p = 5usize;
5275 let s = second_difference_penalty(p); let k = 4usize; let m = k - 1;
5278 let metric = centered_class_metric(m, k);
5279 let raw_total = m * p;
5280 let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
5281 for a in 0..m {
5282 for b in 0..m {
5283 for i in 0..p {
5284 for j in 0..p {
5285 matrix[[a * p + i, b * p + j]] = metric[[a, b]] * s[[i, j]];
5286 }
5287 }
5288 }
5289 }
5290 for i in 0..raw_total {
5292 for j in 0..raw_total {
5293 assert!((matrix[[i, j]] - matrix[[j, i]]).abs() < 1e-14);
5294 }
5295 }
5296 let (evals, _) = FaerEigh::eigh(&matrix, faer::Side::Lower).expect("eigh");
5297 let mut sorted: Vec<f64> = evals.iter().copied().collect();
5298 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
5299 assert!(sorted[0] > -1e-10, "M⊗S must be PSD; min eig {}", sorted[0]);
5301 let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-9).count();
5303 assert_eq!(
5304 zeros,
5305 m * 2,
5306 "nullspace dim must be (K-1)·ns(S); spectrum {sorted:?}"
5307 );
5308 }
5309}