1use crate::model_types::EstimationError;
21use crate::multinomial_reml::{MultinomialLogitRowProgram, multinomial_logit_probabilities_into};
22use ndarray::{Array1, Array2, Array3, ArrayView2};
23
24#[derive(Clone, Debug)]
32pub enum VectorNoise {
33 Isotropic(f64),
35 Diagonal(Array1<f64>),
37 LowRank {
39 diag: Array1<f64>,
40 factor: Array2<f64>,
41 },
42}
43
44impl VectorNoise {
45 pub fn diag_precision(&self, m: usize) -> Result<Array1<f64>, EstimationError> {
49 match self {
50 Self::Isotropic(sigma) => {
51 if !sigma.is_finite() || *sigma <= 0.0 {
52 crate::bail_invalid_estim!(
53 "VectorNoise::Isotropic: σ must be > 0 and finite (got {sigma})",
54 );
55 }
56 let p = 1.0 / (sigma * sigma);
57 Ok(Array1::from_elem(m, p))
58 }
59 Self::Diagonal(sigma) => {
60 if sigma.len() != m {
61 crate::bail_invalid_estim!(
62 "VectorNoise::Diagonal: σ length {} ≠ M={m}",
63 sigma.len()
64 );
65 }
66 let mut out = Array1::<f64>::zeros(m);
67 for j in 0..m {
68 let s = sigma[j];
69 if !s.is_finite() || s <= 0.0 {
70 crate::bail_invalid_estim!(
71 "VectorNoise::Diagonal: σ[{j}] must be > 0 and finite (got {s})",
72 );
73 }
74 out[j] = 1.0 / (s * s);
75 }
76 Ok(out)
77 }
78 Self::LowRank { diag, .. } => {
79 if diag.len() != m {
80 crate::bail_invalid_estim!(
81 "VectorNoise::LowRank: diag length {} ≠ M={m}",
82 diag.len()
83 );
84 }
85 let mut out = Array1::<f64>::zeros(m);
86 for j in 0..m {
87 let d = diag[j];
88 if !d.is_finite() || d <= 0.0 {
89 crate::bail_invalid_estim!(
90 "VectorNoise::LowRank: diag[{j}] must be > 0 (got {d})",
91 );
92 }
93 out[j] = d;
96 }
97 Ok(out)
98 }
99 }
100 }
101}
102
103#[derive(Clone, Debug)]
109pub struct VectorResponseTarget {
110 pub y: Array2<f64>,
112 pub noise: VectorNoise,
114 pub row_weights: Option<Array1<f64>>,
116}
117
118impl VectorResponseTarget {
119 pub fn new(y: Array2<f64>, noise: VectorNoise) -> Self {
120 Self {
121 y,
122 noise,
123 row_weights: None,
124 }
125 }
126
127 pub fn with_row_weights(mut self, w: Array1<f64>) -> Result<Self, EstimationError> {
128 validate_row_weights(&w, self.y.nrows())?;
129 self.row_weights = Some(w);
130 Ok(self)
131 }
132
133 pub fn n(&self) -> usize {
134 self.y.nrows()
135 }
136 pub fn m(&self) -> usize {
137 self.y.ncols()
138 }
139}
140
141pub(crate) const MULTINOMIAL_SIMPLEX_TOL: f64 = 1.0e-9;
155
156pub(crate) fn validate_multinomial_simplex(
164 y: ArrayView2<f64>,
165 context: &str,
166) -> Result<(), EstimationError> {
167 let (n, k) = y.dim();
168 for row in 0..n {
169 let mut row_sum = 0.0_f64;
170 for c in 0..k {
171 let v = y[[row, c]];
172 if !v.is_finite() {
173 crate::bail_invalid_estim!("{context}: y[{row},{c}] must be finite (got {v})");
174 }
175 if v < 0.0 {
176 crate::bail_invalid_estim!(
177 "{context}: multinomial target must be a probability vector \
178 (y_c ≥ 0); got y[{row},{c}] = {v}"
179 );
180 }
181 row_sum += v;
182 }
183 if (row_sum - 1.0).abs() > MULTINOMIAL_SIMPLEX_TOL {
184 crate::bail_invalid_estim!(
185 "{context}: multinomial target rows must sum to 1 (one-hot for \
186 hard labels, or a label-smoothed probability vector); row {row} \
187 sums to {row_sum}. The softmax residual gradient y_a − p_a and \
188 Fisher block p_a δ_ab − p_a p_b are the derivatives of \
189 Σ_c y_c log p_c only when the row mass is 1."
190 );
191 }
192 }
193 Ok(())
194}
195
196fn validate_row_weights(weights: &Array1<f64>, n: usize) -> Result<(), EstimationError> {
197 if weights.len() != n {
198 crate::bail_invalid_estim!("row_weights length {} ≠ N={n}", weights.len());
199 }
200 for (idx, weight) in weights.iter().copied().enumerate() {
201 if !(weight.is_finite() && weight >= 0.0) {
202 crate::bail_invalid_estim!(
203 "row_weights[{idx}] must be finite and non-negative (got {weight})"
204 );
205 }
206 }
207 Ok(())
208}
209
210pub trait VectorLikelihood {
217 fn log_lik(&self, eta: ArrayView2<f64>, y: ArrayView2<f64>) -> Result<f64, EstimationError>;
219
220 fn grad_eta(
222 &self,
223 eta: ArrayView2<f64>,
224 y: ArrayView2<f64>,
225 ) -> Result<Array2<f64>, EstimationError>;
226
227 fn hess_diag(
230 &self,
231 eta: ArrayView2<f64>,
232 y: ArrayView2<f64>,
233 ) -> Result<Array2<f64>, EstimationError>;
234
235 fn hess_block(
250 &self,
251 eta: ArrayView2<f64>,
252 y: ArrayView2<f64>,
253 ) -> Result<Array3<f64>, EstimationError> {
254 let diag = self.hess_diag(eta, y)?;
255 let (n, m) = diag.dim();
256 let mut out = Array3::<f64>::zeros((n, m, m));
257 for row in 0..n {
258 for j in 0..m {
259 out[[row, j, j]] = diag[[row, j]];
260 }
261 }
262 Ok(out)
263 }
264}
265
266pub(crate) fn validate_vector_likelihood_inputs(
267 context: &str,
268 eta: ArrayView2<'_, f64>,
269 y: ArrayView2<'_, f64>,
270 expected_columns: Option<usize>,
271) -> Result<(), EstimationError> {
272 if eta.dim() != y.dim() {
273 crate::bail_invalid_estim!(
274 "{context}: eta shape {:?} does not match response shape {:?}",
275 eta.dim(),
276 y.dim()
277 );
278 }
279 if let Some(expected) = expected_columns
280 && eta.ncols() != expected
281 {
282 crate::bail_invalid_estim!(
283 "{context}: eta has {} columns; expected {expected}",
284 eta.ncols()
285 );
286 }
287 if let Some(((row, column), value)) = eta.indexed_iter().find(|(_, value)| !value.is_finite()) {
288 crate::bail_invalid_estim!("{context}: eta[{row},{column}] must be finite, got {value}");
289 }
290 if let Some(((row, column), value)) = y.indexed_iter().find(|(_, value)| !value.is_finite()) {
291 crate::bail_invalid_estim!(
292 "{context}: response[{row},{column}] must be finite, got {value}"
293 );
294 }
295 Ok(())
296}
297
298#[derive(Clone, Debug)]
309pub struct GaussianVectorLikelihood {
310 pub precision: Array1<f64>,
314 pub factor: Option<Array2<f64>>,
318 pub row_weights: Option<Array1<f64>>,
320}
321
322impl GaussianVectorLikelihood {
323 pub fn from_target(target: &VectorResponseTarget) -> Result<Self, EstimationError> {
324 if let Some(weights) = target.row_weights.as_ref() {
325 validate_row_weights(weights, target.n())?;
326 }
327 let precision = target.noise.diag_precision(target.m())?;
328 let factor = match &target.noise {
329 VectorNoise::LowRank { factor, .. } => {
330 if factor.nrows() != target.m() {
331 crate::bail_invalid_estim!(
332 "VectorNoise::LowRank: factor has {} rows but M={}",
333 factor.nrows(),
334 target.m()
335 );
336 }
337 for ((row, col), value) in factor.indexed_iter() {
338 if !value.is_finite() {
339 crate::bail_invalid_estim!(
340 "VectorNoise::LowRank: factor[{row},{col}] must be finite (got {value})"
341 );
342 }
343 }
344 Some(factor.clone())
345 }
346 _ => None,
347 };
348 Ok(Self {
349 precision,
350 factor,
351 row_weights: target.row_weights.clone(),
352 })
353 }
354
355 #[inline]
356 fn row_weight(&self, n: usize) -> f64 {
357 self.row_weights.as_ref().map_or(1.0, |w| w[n])
358 }
359}
360
361impl VectorLikelihood for GaussianVectorLikelihood {
362 fn log_lik(&self, eta: ArrayView2<f64>, y: ArrayView2<f64>) -> Result<f64, EstimationError> {
363 validate_vector_likelihood_inputs(
364 "GaussianVectorLikelihood::log_lik",
365 eta,
366 y,
367 Some(self.precision.len()),
368 )?;
369 let m = eta.ncols();
370 let rank = self.factor.as_ref().map_or(0, |f| f.ncols());
371 let mut acc = 0.0;
372 let mut ftr = vec![0.0f64; rank];
374 for n in 0..eta.nrows() {
375 let w = self.row_weight(n);
376 let mut row_acc = 0.0;
378 for j in 0..m {
379 let r = y[[n, j]] - eta[[n, j]];
380 row_acc += self.precision[j] * r * r;
381 }
382 if let Some(f) = self.factor.as_ref() {
384 for k in 0..rank {
385 ftr[k] = 0.0;
386 }
387 for j in 0..m {
388 let r = y[[n, j]] - eta[[n, j]];
389 for k in 0..rank {
390 ftr[k] += f[[j, k]] * r;
391 }
392 }
393 for k in 0..rank {
394 row_acc += ftr[k] * ftr[k];
395 }
396 }
397 acc += w * row_acc;
398 }
399 Ok(-0.5 * acc)
400 }
401
402 fn grad_eta(
403 &self,
404 eta: ArrayView2<f64>,
405 y: ArrayView2<f64>,
406 ) -> Result<Array2<f64>, EstimationError> {
407 validate_vector_likelihood_inputs(
408 "GaussianVectorLikelihood::grad_eta",
409 eta,
410 y,
411 Some(self.precision.len()),
412 )?;
413 let (n_rows, n_cols) = eta.dim();
414 let rank = self.factor.as_ref().map_or(0, |f| f.ncols());
415 let mut out = Array2::<f64>::zeros((n_rows, n_cols));
416 let mut ftr = vec![0.0f64; rank];
417 for n in 0..n_rows {
418 let w = self.row_weight(n);
419 for j in 0..n_cols {
421 out[[n, j]] = w * self.precision[j] * (y[[n, j]] - eta[[n, j]]);
422 }
423 if let Some(f) = self.factor.as_ref() {
425 for k in 0..rank {
426 ftr[k] = 0.0;
427 }
428 for j in 0..n_cols {
429 let r = y[[n, j]] - eta[[n, j]];
430 for k in 0..rank {
431 ftr[k] += f[[j, k]] * r;
432 }
433 }
434 for j in 0..n_cols {
435 let mut s = 0.0;
436 for k in 0..rank {
437 s += f[[j, k]] * ftr[k];
438 }
439 out[[n, j]] += w * s;
440 }
441 }
442 }
443 Ok(out)
444 }
445
446 fn hess_diag(
447 &self,
448 eta: ArrayView2<f64>,
449 y: ArrayView2<f64>,
450 ) -> Result<Array2<f64>, EstimationError> {
451 validate_vector_likelihood_inputs(
452 "GaussianVectorLikelihood::hess_diag",
453 eta,
454 y,
455 Some(self.precision.len()),
456 )?;
457 let (n_rows, n_cols) = eta.dim();
464 let mut out = Array2::<f64>::zeros((n_rows, n_cols));
465 let f_row_sqsum: Option<Array1<f64>> = self.factor.as_ref().map(|f| {
467 let m = f.nrows();
468 let r = f.ncols();
469 let mut s = Array1::<f64>::zeros(m);
470 for j in 0..m {
471 let mut acc = 0.0;
472 for k in 0..r {
473 let v = f[[j, k]];
474 acc += v * v;
475 }
476 s[j] = acc;
477 }
478 s
479 });
480 for n in 0..n_rows {
481 let w = self.row_weight(n);
482 for j in 0..n_cols {
483 let mut d = self.precision[j];
484 if let Some(s) = f_row_sqsum.as_ref() {
485 d += s[j];
486 }
487 out[[n, j]] = w * d;
488 }
489 }
490 Ok(out)
491 }
492
493 fn hess_block(
494 &self,
495 eta: ArrayView2<f64>,
496 y: ArrayView2<f64>,
497 ) -> Result<Array3<f64>, EstimationError> {
498 validate_vector_likelihood_inputs(
507 "GaussianVectorLikelihood::hess_block",
508 eta,
509 y,
510 Some(self.precision.len()),
511 )?;
512 let (n_rows, m) = eta.dim();
513 let rank = self.factor.as_ref().map_or(0, |f| f.ncols());
514
515 let gram: Option<Array2<f64>> = self.factor.as_ref().map(|f| {
518 let mut g = Array2::<f64>::zeros((m, m));
519 for a in 0..m {
520 for b in a..m {
521 let mut acc = 0.0;
522 for k in 0..rank {
523 acc += f[[a, k]] * f[[b, k]];
524 }
525 g[[a, b]] = acc;
526 g[[b, a]] = acc;
527 }
528 }
529 g
530 });
531
532 let mut out = Array3::<f64>::zeros((n_rows, m, m));
533 for n in 0..n_rows {
534 let w = self.row_weight(n);
535 for a in 0..m {
536 for b in 0..m {
537 let mut val = if a == b { self.precision[a] } else { 0.0 };
538 if let Some(g) = gram.as_ref() {
539 val += g[[a, b]];
540 }
541 out[[n, a, b]] = w * val;
542 }
543 }
544 }
545 Ok(out)
546 }
547}
548
549#[derive(Clone, Debug)]
601pub struct MultinomialLogitLikelihood {
602 pub active_classes: usize,
604 pub row_weights: Option<Array1<f64>>,
606}
607
608impl MultinomialLogitLikelihood {
609 pub fn with_classes(total_classes: usize) -> Result<Self, EstimationError> {
611 if total_classes < 2 {
612 crate::bail_invalid_estim!(
613 "MultinomialLogitLikelihood requires K ≥ 2 classes (got {total_classes})"
614 );
615 }
616 Ok(Self {
617 active_classes: total_classes - 1,
618 row_weights: None,
619 })
620 }
621
622 pub fn with_row_weights(mut self, w: Array1<f64>) -> Result<Self, EstimationError> {
624 validate_row_weights(&w, w.len())?;
625 self.row_weights = Some(w);
626 Ok(self)
627 }
628
629 #[inline]
631 pub fn total_classes(&self) -> usize {
632 self.active_classes + 1
633 }
634
635 #[inline]
636 fn row_weight(&self, n: usize) -> f64 {
637 self.row_weights.as_ref().map_or(1.0, |w| w[n])
638 }
639
640 pub fn softmax_with_baseline(eta_active: &[f64], out: &mut [f64]) {
649 multinomial_logit_probabilities_into(eta_active, out);
650 }
651
652 pub fn probabilities(&self, eta: ArrayView2<f64>) -> Array2<f64> {
656 let n = eta.nrows();
657 let m = self.active_classes;
658 assert_eq!(eta.ncols(), m, "η must have K-1 columns");
659 let k = self.total_classes();
660 let eta = eta.as_standard_layout();
661 let eta_values = eta
662 .as_slice()
663 .expect("standard-layout multinomial logits are contiguous");
664 let mut probs = Array2::<f64>::zeros((n, k));
665 let probs_values = probs
666 .as_slice_mut()
667 .expect("fresh multinomial probabilities are contiguous");
668 let mut eta_row = vec![0.0_f64; m];
669 let mut probs_row = vec![0.0_f64; k];
670 for row in 0..n {
671 eta_row.copy_from_slice(&eta_values[row * m..(row + 1) * m]);
672 Self::softmax_with_baseline(&eta_row, &mut probs_row);
673 probs_values[row * k..(row + 1) * k].copy_from_slice(&probs_row);
674 }
675 probs
676 }
677
678 #[inline]
679 fn row_program<'row>(
680 &self,
681 row: usize,
682 eta: &'row [f64],
683 response: &'row [f64],
684 ) -> Result<MultinomialLogitRowProgram<'row>, EstimationError> {
685 MultinomialLogitRowProgram::new(eta, response, self.row_weight(row)).map_err(|error| {
686 EstimationError::InvalidInput(format!("invalid multinomial row {row}: {error}"))
687 })
688 }
689
690 pub(crate) fn value_gradient_hessian(
694 &self,
695 eta: ArrayView2<f64>,
696 y: ArrayView2<f64>,
697 ) -> Result<(f64, Array2<f64>, Array3<f64>), EstimationError> {
698 let n = eta.nrows();
699 let m = self.active_classes;
700 let k = self.total_classes();
701 if y.dim() != (n, k) {
702 crate::bail_invalid_estim!(
703 "MultinomialLogitLikelihood::value_gradient_hessian: response shape {:?} must be ({n}, {k})",
704 y.dim()
705 );
706 }
707 validate_vector_likelihood_inputs(
708 "MultinomialLogitLikelihood::value_gradient_hessian active response",
709 eta,
710 y.slice(ndarray::s![.., ..m]),
711 Some(m),
712 )?;
713 let eta = eta.as_standard_layout();
714 let eta_values = eta
715 .as_slice()
716 .expect("standard-layout multinomial logits are contiguous");
717 let y = y.as_standard_layout();
718 let response_values = y
719 .as_slice()
720 .expect("standard-layout multinomial responses are contiguous");
721 let mut gradient_log_likelihood = Array2::<f64>::zeros((n, m));
722 let mut hessian = Array3::<f64>::zeros((n, m, m));
723 let gradient_values = gradient_log_likelihood
724 .as_slice_mut()
725 .expect("fresh multinomial gradient is contiguous");
726 let hessian_values = hessian
727 .as_slice_mut()
728 .expect("fresh multinomial Hessian is contiguous");
729 let mut eta_row = vec![0.0_f64; m];
730 let mut response_row = vec![0.0_f64; k];
731 let mut probabilities = vec![0.0_f64; k];
732 let mut gradient_nll = vec![0.0_f64; m];
733 let mut hessian_row = vec![0.0_f64; m * m];
734 let mut negative_log_likelihood = 0.0_f64;
735 for row in 0..n {
736 eta_row.copy_from_slice(&eta_values[row * m..(row + 1) * m]);
737 response_row.copy_from_slice(&response_values[row * k..(row + 1) * k]);
738 let program = self.row_program(row, &eta_row, &response_row)?;
739 negative_log_likelihood += program.value_gradient_hessian_into(
740 &mut probabilities,
741 &mut gradient_nll,
742 &mut hessian_row,
743 );
744 for axis in 0..m {
745 gradient_values[row * m + axis] = -gradient_nll[axis];
746 }
747 hessian_values[row * m * m..(row + 1) * m * m].copy_from_slice(&hessian_row);
748 }
749 Ok((-negative_log_likelihood, gradient_log_likelihood, hessian))
750 }
751
752 pub(crate) fn value_gradient(
754 &self,
755 eta: ArrayView2<f64>,
756 y: ArrayView2<f64>,
757 ) -> Result<(f64, Array2<f64>), EstimationError> {
758 let n = eta.nrows();
759 let m = self.active_classes;
760 let k = self.total_classes();
761 if y.dim() != (n, k) {
762 crate::bail_invalid_estim!(
763 "MultinomialLogitLikelihood::value_gradient: response shape {:?} must be ({n}, {k})",
764 y.dim()
765 );
766 }
767 validate_vector_likelihood_inputs(
768 "MultinomialLogitLikelihood::value_gradient active response",
769 eta,
770 y.slice(ndarray::s![.., ..m]),
771 Some(m),
772 )?;
773 let eta = eta.as_standard_layout();
774 let eta_values = eta
775 .as_slice()
776 .expect("standard-layout multinomial logits are contiguous");
777 let y = y.as_standard_layout();
778 let response_values = y
779 .as_slice()
780 .expect("standard-layout multinomial responses are contiguous");
781 let mut gradient_log_likelihood = Array2::<f64>::zeros((n, m));
782 let gradient_values = gradient_log_likelihood
783 .as_slice_mut()
784 .expect("fresh multinomial gradient is contiguous");
785 let mut eta_row = vec![0.0_f64; m];
786 let mut response_row = vec![0.0_f64; k];
787 let mut probabilities = vec![0.0_f64; k];
788 let mut gradient_nll = vec![0.0_f64; m];
789 let mut negative_log_likelihood = 0.0_f64;
790 for row in 0..n {
791 eta_row.copy_from_slice(&eta_values[row * m..(row + 1) * m]);
792 response_row.copy_from_slice(&response_values[row * k..(row + 1) * k]);
793 let program = self.row_program(row, &eta_row, &response_row)?;
794 negative_log_likelihood +=
795 program.value_gradient_into(&mut probabilities, &mut gradient_nll);
796 for axis in 0..m {
797 gradient_values[row * m + axis] = -gradient_nll[axis];
798 }
799 }
800 Ok((-negative_log_likelihood, gradient_log_likelihood))
801 }
802}
803
804impl VectorLikelihood for MultinomialLogitLikelihood {
805 fn log_lik(&self, eta: ArrayView2<f64>, y: ArrayView2<f64>) -> Result<f64, EstimationError> {
806 let n = eta.nrows();
807 let m = self.active_classes;
808 let k = self.total_classes();
809 if y.dim() != (n, k) {
810 crate::bail_invalid_estim!(
811 "MultinomialLogitLikelihood::log_lik: response shape {:?} must be ({n}, {k})",
812 y.dim()
813 );
814 }
815 validate_vector_likelihood_inputs(
816 "MultinomialLogitLikelihood::log_lik active response",
817 eta,
818 y.slice(ndarray::s![.., ..m]),
819 Some(m),
820 )?;
821 let mut eta_row = vec![0.0_f64; m];
822 let mut response_row = vec![0.0_f64; k];
823 let mut negative_log_likelihood = 0.0_f64;
824 for row in 0..n {
825 for axis in 0..m {
826 eta_row[axis] = eta[[row, axis]];
827 }
828 for class in 0..k {
829 response_row[class] = y[[row, class]];
830 }
831 negative_log_likelihood += self
832 .row_program(row, &eta_row, &response_row)?
833 .negative_log_likelihood();
834 }
835 Ok(-negative_log_likelihood)
836 }
837
838 fn grad_eta(
839 &self,
840 eta: ArrayView2<f64>,
841 y: ArrayView2<f64>,
842 ) -> Result<Array2<f64>, EstimationError> {
843 Ok(self.value_gradient(eta, y)?.1)
844 }
845
846 fn hess_diag(
847 &self,
848 eta: ArrayView2<f64>,
849 y: ArrayView2<f64>,
850 ) -> Result<Array2<f64>, EstimationError> {
851 let n = eta.nrows();
856 let m = self.active_classes;
857 let k = self.total_classes();
858 if y.dim() != (n, k) {
859 crate::bail_invalid_estim!(
860 "MultinomialLogitLikelihood::hess_diag: response shape {:?} must be ({n}, {k})",
861 y.dim()
862 );
863 }
864 validate_vector_likelihood_inputs(
865 "MultinomialLogitLikelihood::hess_diag active response",
866 eta,
867 y.slice(ndarray::s![.., ..m]),
868 Some(m),
869 )?;
870 let mut out = Array2::<f64>::zeros((n, m));
871 let mut eta_row = vec![0.0_f64; m];
872 let mut response_row = vec![0.0_f64; k];
873 let mut probabilities = vec![0.0_f64; k];
874 let mut diagonal = vec![0.0_f64; m];
875 for row in 0..n {
876 for axis in 0..m {
877 eta_row[axis] = eta[[row, axis]];
878 }
879 for class in 0..k {
880 response_row[class] = y[[row, class]];
881 }
882 self.row_program(row, &eta_row, &response_row)?
883 .hessian_diagonal_into(&mut probabilities, &mut diagonal);
884 for axis in 0..m {
885 out[[row, axis]] = diagonal[axis];
886 }
887 }
888 Ok(out)
889 }
890
891 fn hess_block(
892 &self,
893 eta: ArrayView2<f64>,
894 y: ArrayView2<f64>,
895 ) -> Result<Array3<f64>, EstimationError> {
896 Ok(self.value_gradient_hessian(eta, y)?.2)
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use ndarray::{Array1, Array2};
904
905 macro_rules! expect_invalid_input {
909 ($result:expr, $needle:expr $(,)?) => {{
910 let needle: &str = $needle;
911 match $result {
912 Ok(_) => {
913 panic!("expected EstimationError::InvalidInput containing `{needle}`, got Ok")
914 }
915 Err(EstimationError::InvalidInput(msg)) => {
916 assert!(
917 msg.contains(needle),
918 "InvalidInput message `{msg}` does not contain `{needle}`"
919 );
920 msg
921 }
922 Err(other) => panic!(
923 "expected EstimationError::InvalidInput containing `{needle}`, got {other:?}"
924 ),
925 }
926 }};
927 }
928
929 fn dummy_target(n: usize, m: usize) -> VectorResponseTarget {
930 VectorResponseTarget::new(Array2::<f64>::zeros((n, m)), VectorNoise::Isotropic(1.0))
931 }
932
933 #[test]
934 fn with_row_weights_rejects_wrong_length() {
935 let target = dummy_target(4, 2);
936 let weights = Array1::from(vec![1.0, 1.0, 1.0]);
937 expect_invalid_input!(target.with_row_weights(weights), "row_weights length");
938 }
939
940 #[test]
941 fn with_row_weights_rejects_negative_entry() {
942 let target = dummy_target(3, 2);
943 let weights = Array1::from(vec![1.0, -0.5, 2.0]);
944 expect_invalid_input!(
945 target.with_row_weights(weights),
946 "must be finite and non-negative",
947 );
948 }
949
950 #[test]
951 fn with_row_weights_rejects_nan_entry() {
952 let target = dummy_target(3, 2);
953 let weights = Array1::from(vec![1.0, f64::NAN, 2.0]);
954 expect_invalid_input!(
955 target.with_row_weights(weights),
956 "must be finite and non-negative",
957 );
958 }
959
960 #[test]
961 fn with_row_weights_rejects_infinite_entry() {
962 let target = dummy_target(3, 2);
963 let weights = Array1::from(vec![1.0, f64::INFINITY, 2.0]);
964 expect_invalid_input!(
965 target.with_row_weights(weights),
966 "must be finite and non-negative",
967 );
968 }
969
970 #[test]
971 fn with_row_weights_accepts_zero_and_positive() {
972 let target = dummy_target(3, 2);
973 let weights = Array1::from(vec![0.0, 1.5, 3.0]);
974 let weighted = target
975 .with_row_weights(weights)
976 .expect("zero / positive weights should be accepted");
977 assert!(weighted.row_weights.is_some());
978 }
979
980 #[test]
981 fn from_target_rejects_low_rank_factor_with_wrong_row_count() {
982 let n = 4;
983 let m = 3;
984 let factor = Array2::from_shape_vec((2, 2), vec![0.1, 0.2, 0.3, 0.4]).unwrap();
986 let target = VectorResponseTarget::new(
987 Array2::<f64>::zeros((n, m)),
988 VectorNoise::LowRank {
989 diag: Array1::from(vec![1.0; m]),
990 factor,
991 },
992 );
993 expect_invalid_input!(GaussianVectorLikelihood::from_target(&target), "factor has",);
994 }
995
996 #[test]
997 fn from_target_rejects_non_finite_low_rank_factor_entry() {
998 let n = 4;
999 let m = 3;
1000 let mut factor = Array2::<f64>::zeros((m, 2));
1001 factor[[1, 0]] = f64::NAN;
1002 let target = VectorResponseTarget::new(
1003 Array2::<f64>::zeros((n, m)),
1004 VectorNoise::LowRank {
1005 diag: Array1::from(vec![1.0; m]),
1006 factor,
1007 },
1008 );
1009 expect_invalid_input!(
1010 GaussianVectorLikelihood::from_target(&target),
1011 "must be finite",
1012 );
1013 }
1014
1015 #[test]
1016 fn from_target_accepts_well_formed_low_rank_factor() {
1017 let n = 2;
1018 let m = 3;
1019 let factor = Array2::from_shape_vec((m, 2), vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]).unwrap();
1020 let target = VectorResponseTarget::new(
1021 Array2::<f64>::zeros((n, m)),
1022 VectorNoise::LowRank {
1023 diag: Array1::from(vec![1.0; m]),
1024 factor: factor.clone(),
1025 },
1026 );
1027 let lik = GaussianVectorLikelihood::from_target(&target)
1028 .expect("well-formed low-rank factor should be accepted");
1029 let stored = lik.factor.expect("low-rank factor should be carried");
1030 assert_eq!(stored.dim(), (m, 2));
1031 for ((i, j), v) in stored.indexed_iter() {
1032 assert_eq!(*v, factor[[i, j]]);
1033 }
1034 assert_eq!(m, lik.precision.len());
1042 }
1043
1044 #[test]
1045 fn from_target_propagates_row_weight_length_mismatch() {
1046 let n = 3;
1047 let m = 2;
1048 let target = VectorResponseTarget {
1049 y: Array2::<f64>::zeros((n, m)),
1050 noise: VectorNoise::Isotropic(1.0),
1051 row_weights: Some(Array1::from(vec![1.0, 1.0])),
1052 };
1053 expect_invalid_input!(
1054 GaussianVectorLikelihood::from_target(&target),
1055 "row_weights length",
1056 );
1057 }
1058
1059 #[test]
1060 fn vector_likelihood_rejects_nonfinite_optimizer_state_without_panicking_932() {
1061 let target = dummy_target(1, 2);
1062 let likelihood =
1063 GaussianVectorLikelihood::from_target(&target).expect("finite Gaussian vector target");
1064 let eta = Array2::from_shape_vec((1, 2), vec![0.0, f64::NAN]).expect("eta shape");
1065 expect_invalid_input!(
1066 likelihood.log_lik(eta.view(), target.y.view()),
1067 "eta[0,1] must be finite",
1068 );
1069 }
1070
1071 #[test]
1072 fn multinomial_row_validation_propagates_as_typed_likelihood_error_932() {
1073 let likelihood = MultinomialLogitLikelihood::with_classes(3)
1074 .expect("three-class reference-coded likelihood");
1075 let eta =
1076 Array2::from_shape_vec((1, 2), vec![f64::INFINITY, 0.0]).expect("active eta shape");
1077 let response =
1078 Array2::from_shape_vec((1, 3), vec![1.0, 0.0, 0.0]).expect("simplex response shape");
1079 expect_invalid_input!(
1080 likelihood.log_lik(eta.view(), response.view()),
1081 "eta[0,0] must be finite",
1082 );
1083 }
1084}