1use crate::alignment::{dp_alignment_core, srsf_transform};
4use crate::helpers::simpsons_weights;
5use crate::matrix::FdMatrix;
6
7use super::{
8 apply_warps_to_srsfs, beta_converged, init_identity_warps, srsf_fitted_values, ElasticConfig,
9};
10
11#[derive(Debug, Clone, PartialEq)]
13#[non_exhaustive]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct ElasticLogisticResult {
16 pub alpha: f64,
18 pub beta: Vec<f64>,
20 pub probabilities: Vec<f64>,
22 pub predicted_classes: Vec<usize>,
24 pub accuracy: f64,
26 pub loss: f64,
28 pub gammas: FdMatrix,
30 pub aligned_srsfs: FdMatrix,
32 pub n_iter: usize,
34}
35
36#[must_use = "expensive computation whose result should not be discarded"]
54pub fn elastic_logistic(
55 data: &FdMatrix,
56 y: &[i8],
57 argvals: &[f64],
58 _ncomp_beta: usize,
59 lambda: f64,
60 max_iter: usize,
61 tol: f64,
62) -> Result<ElasticLogisticResult, crate::FdarError> {
63 let (n, m) = data.shape();
64 if n < 2 || m < 2 || y.len() != n || argvals.len() != m {
65 return Err(crate::FdarError::InvalidDimension {
66 parameter: "data/y/argvals",
67 expected: "n >= 2, m >= 2, y.len() == n, argvals.len() == m".to_string(),
68 actual: format!(
69 "n={}, m={}, y.len()={}, argvals.len()={}",
70 n,
71 m,
72 y.len(),
73 argvals.len()
74 ),
75 });
76 }
77
78 let weights = simpsons_weights(argvals);
79 let q_all = srsf_transform(data, argvals);
80 let mut gammas = init_identity_warps(n, argvals);
81 let mut beta = vec![0.0; m];
82 let mut alpha = 0.0;
83 let mut n_iter = 0;
84
85 for iter in 0..max_iter {
86 n_iter = iter + 1;
87
88 let q_aligned = apply_warps_to_srsfs(&q_all, &gammas, argvals);
89 let (grad_a, grad_beta, prob) =
90 logistic_gradients(&q_aligned, &beta, &weights, alpha, y, lambda);
91
92 let loss_current = logistic_loss(&prob, y, &beta, lambda);
93 let grad_norm_sq: f64 = grad_a * grad_a + grad_beta.iter().map(|&g| g * g).sum::<f64>();
94
95 let step = armijo_line_search_logistic(
96 &q_aligned,
97 alpha,
98 &beta,
99 grad_a,
100 &grad_beta,
101 &weights,
102 y,
103 lambda,
104 loss_current,
105 grad_norm_sq,
106 );
107
108 let beta_new: Vec<f64> = beta
109 .iter()
110 .zip(grad_beta.iter())
111 .map(|(&b, &g)| b - step * g)
112 .collect();
113 let alpha_new = alpha - step * grad_a;
114
115 if beta_converged(&beta_new, &beta, tol) && iter > 0 {
116 beta = beta_new;
117 alpha = alpha_new;
118 break;
119 }
120
121 beta = beta_new;
122 alpha = alpha_new;
123
124 update_logistic_warps(&mut gammas, &q_all, &beta, y, argvals, lambda * 0.01);
125 }
126
127 let aligned_srsfs = apply_warps_to_srsfs(&q_all, &gammas, argvals);
129 let (probabilities, predicted_classes, accuracy, loss) =
130 compute_logistic_predictions(&aligned_srsfs, &beta, &weights, alpha, y, lambda);
131
132 Ok(ElasticLogisticResult {
133 alpha,
134 beta,
135 probabilities,
136 predicted_classes,
137 accuracy,
138 loss,
139 gammas,
140 aligned_srsfs,
141 n_iter,
142 })
143}
144
145#[must_use = "expensive computation whose result should not be discarded"]
149pub fn elastic_logistic_with_config(
150 data: &FdMatrix,
151 y: &[i8],
152 argvals: &[f64],
153 config: &ElasticConfig,
154) -> Result<ElasticLogisticResult, crate::FdarError> {
155 elastic_logistic(
156 data,
157 y,
158 argvals,
159 config.ncomp_beta,
160 config.lambda,
161 config.max_iter,
162 config.tol,
163 )
164}
165
166pub fn predict_elastic_logistic(
176 fit: &ElasticLogisticResult,
177 new_data: &FdMatrix,
178 argvals: &[f64],
179) -> Vec<f64> {
180 let weights = simpsons_weights(argvals);
181 let q_new = srsf_transform(new_data, argvals);
182 let eta = srsf_fitted_values(&q_new, &fit.beta, &weights, fit.alpha);
183 eta.iter().map(|&e| 1.0 / (1.0 + (-e).exp())).collect()
184}
185
186impl ElasticLogisticResult {
187 pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Vec<f64> {
189 predict_elastic_logistic(self, new_data, argvals)
190 }
191}
192
193#[derive(Debug, Clone, PartialEq)]
212#[non_exhaustive]
213#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
214pub struct ElasticMultinomialResult {
215 pub n_classes: usize,
217 pub classes: Vec<usize>,
219 pub class_models: Vec<ElasticLogisticResult>,
221 pub train_probabilities: FdMatrix,
223 pub predicted_classes: Vec<usize>,
225 pub train_accuracy: f64,
227}
228
229#[must_use = "expensive computation whose result should not be discarded"]
252pub fn elastic_multinomial(
253 data: &FdMatrix,
254 y: &[usize],
255 argvals: &[f64],
256 ncomp_beta: usize,
257 lambda: f64,
258 max_iter: usize,
259 tol: f64,
260) -> Result<ElasticMultinomialResult, crate::FdarError> {
261 let (n, m) = data.shape();
262
263 if n == 0 || y.len() != n {
265 return Err(crate::FdarError::InvalidDimension {
266 parameter: "data/y",
267 expected: "n >= 1, y.len() == n".to_string(),
268 actual: format!("n={}, y.len()={}", n, y.len()),
269 });
270 }
271 if m < 2 || argvals.len() != m {
272 return Err(crate::FdarError::InvalidDimension {
273 parameter: "data/argvals",
274 expected: "m >= 2, argvals.len() == m".to_string(),
275 actual: format!("m={}, argvals.len()={}", m, argvals.len()),
276 });
277 }
278
279 let mut sorted_labels: Vec<usize> = y.to_vec();
280 sorted_labels.sort_unstable();
281 sorted_labels.dedup();
282 let k = sorted_labels.len();
283
284 if k < 2 {
285 return Err(crate::FdarError::InvalidParameter {
286 parameter: "y",
287 message: format!(
288 "at least 2 distinct classes required; found {} distinct label(s)",
289 k
290 ),
291 });
292 }
293 for (idx, &label) in sorted_labels.iter().enumerate() {
295 if label != idx {
296 return Err(crate::FdarError::InvalidParameter {
297 parameter: "y",
298 message: format!(
299 "labels must form the contiguous range 0..{} but found label {} at position {}",
300 k, label, idx
301 ),
302 });
303 }
304 }
305
306 let classes = sorted_labels;
308 let mut class_models: Vec<ElasticLogisticResult> = Vec::with_capacity(k);
309 for &class_k in &classes {
310 let labels_k: Vec<i8> = y
311 .iter()
312 .map(|&lbl| if lbl == class_k { 1i8 } else { -1i8 })
313 .collect();
314 let model_k =
315 elastic_logistic(data, &labels_k, argvals, ncomp_beta, lambda, max_iter, tol)?;
316 class_models.push(model_k);
317 }
318
319 let mut train_probabilities = FdMatrix::zeros(n, k);
321 for (col_k, model_k) in class_models.iter().enumerate() {
322 for row_i in 0..n {
323 train_probabilities[(row_i, col_k)] = model_k.probabilities[row_i];
324 }
325 }
326 for row_i in 0..n {
328 let row_sum: f64 = (0..k).map(|col| train_probabilities[(row_i, col)]).sum();
329 if row_sum < 1e-15 {
330 for col in 0..k {
332 train_probabilities[(row_i, col)] = 1.0 / k as f64;
333 }
334 } else {
335 let scale = 1.0 / row_sum;
336 for col in 0..k {
337 train_probabilities[(row_i, col)] *= scale;
338 }
339 }
340 }
341
342 let predicted_classes: Vec<usize> = (0..n)
344 .map(|row_i| {
345 let mut best_k = 0;
346 let mut best_p = train_probabilities[(row_i, 0)];
347 for col in 1..k {
348 let p = train_probabilities[(row_i, col)];
349 if p > best_p {
350 best_p = p;
351 best_k = col;
352 }
353 }
354 classes[best_k]
355 })
356 .collect();
357
358 let train_accuracy = predicted_classes
359 .iter()
360 .zip(y.iter())
361 .filter(|(&pred, &true_lbl)| pred == true_lbl)
362 .count() as f64
363 / n as f64;
364
365 Ok(ElasticMultinomialResult {
366 n_classes: k,
367 classes,
368 class_models,
369 train_probabilities,
370 predicted_classes,
371 train_accuracy,
372 })
373}
374
375pub fn predict_elastic_multinomial(
391 fit: &ElasticMultinomialResult,
392 new_data: &FdMatrix,
393 argvals: &[f64],
394) -> Vec<usize> {
395 let n_new = new_data.nrows();
396 if n_new == 0 {
397 return Vec::new();
398 }
399 let k = fit.n_classes;
400
401 let mut prob_matrix = FdMatrix::zeros(n_new, k);
403 for (col_k, model_k) in fit.class_models.iter().enumerate() {
404 let probs_k = predict_elastic_logistic(model_k, new_data, argvals);
405 for row_i in 0..n_new {
406 prob_matrix[(row_i, col_k)] = probs_k[row_i];
407 }
408 }
409
410 for row_i in 0..n_new {
412 let row_sum: f64 = (0..k).map(|col| prob_matrix[(row_i, col)]).sum();
413 if row_sum < 1e-15 {
414 for col in 0..k {
415 prob_matrix[(row_i, col)] = 1.0 / k as f64;
416 }
417 } else {
418 let scale = 1.0 / row_sum;
419 for col in 0..k {
420 prob_matrix[(row_i, col)] *= scale;
421 }
422 }
423 }
424
425 (0..n_new)
427 .map(|row_i| {
428 let mut best_k = 0;
429 let mut best_p = prob_matrix[(row_i, 0)];
430 for col in 1..k {
431 let p = prob_matrix[(row_i, col)];
432 if p > best_p {
433 best_p = p;
434 best_k = col;
435 }
436 }
437 fit.classes[best_k]
438 })
439 .collect()
440}
441
442impl ElasticMultinomialResult {
443 pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Vec<usize> {
445 predict_elastic_multinomial(self, new_data, argvals)
446 }
447}
448
449fn logistic_loss(prob: &[f64], y: &[i8], beta: &[f64], lambda: f64) -> f64 {
453 let n = prob.len();
454 let mut loss = 0.0;
455 for i in 0..n {
456 let target = if y[i] == 1 { 1.0 } else { 0.0 };
457 let p = prob[i].clamp(1e-15, 1.0 - 1e-15);
458 loss -= target * p.ln() + (1.0 - target) * (1.0 - p).ln();
459 }
460 loss /= n as f64;
461 loss += 0.5 * lambda * beta.iter().map(|&b| b * b).sum::<f64>();
463 loss
464}
465
466fn logistic_gradients(
468 q_aligned: &FdMatrix,
469 beta: &[f64],
470 weights: &[f64],
471 alpha: f64,
472 y: &[i8],
473 lambda: f64,
474) -> (f64, Vec<f64>, Vec<f64>) {
475 let (n, m) = q_aligned.shape();
476 let eta = srsf_fitted_values(q_aligned, beta, weights, alpha);
477 let prob: Vec<f64> = eta.iter().map(|&e| 1.0 / (1.0 + (-e).exp())).collect();
478
479 let mut grad_a = 0.0;
480 for i in 0..n {
481 let target = if y[i] == 1 { 1.0 } else { 0.0 };
482 grad_a += prob[i] - target;
483 }
484 grad_a /= n as f64;
485
486 let mut grad_beta = vec![0.0; m];
487 for j in 0..m {
488 for i in 0..n {
489 let target = if y[i] == 1 { 1.0 } else { 0.0 };
490 grad_beta[j] += (prob[i] - target) * q_aligned[(i, j)] * weights[j];
491 }
492 grad_beta[j] /= n as f64;
493 grad_beta[j] += lambda * beta[j];
494 }
495
496 (grad_a, grad_beta, prob)
497}
498
499fn armijo_line_search_logistic(
501 q_aligned: &FdMatrix,
502 alpha: f64,
503 beta: &[f64],
504 grad_a: f64,
505 grad_beta: &[f64],
506 weights: &[f64],
507 y: &[i8],
508 lambda: f64,
509 loss_current: f64,
510 grad_norm_sq: f64,
511) -> f64 {
512 let mut step = 1.0;
513 for _ in 0..20 {
514 let alpha_trial = alpha - step * grad_a;
515 let beta_trial: Vec<f64> = beta
516 .iter()
517 .zip(grad_beta.iter())
518 .map(|(&b, &g)| b - step * g)
519 .collect();
520 let eta_trial = srsf_fitted_values(q_aligned, &beta_trial, weights, alpha_trial);
521 let prob_trial: Vec<f64> = eta_trial
522 .iter()
523 .map(|&e| 1.0 / (1.0 + (-e).exp()))
524 .collect();
525 let loss_trial = logistic_loss(&prob_trial, y, &beta_trial, lambda);
526 if loss_trial <= loss_current - 1e-4 * step * grad_norm_sq {
527 break;
528 }
529 step *= 0.5;
530 }
531 step
532}
533
534fn update_logistic_warps(
536 gammas: &mut FdMatrix,
537 q_all: &FdMatrix,
538 beta: &[f64],
539 y: &[i8],
540 argvals: &[f64],
541 lambda: f64,
542) {
543 let (n, m) = q_all.shape();
544 for i in 0..n {
545 let qi: Vec<f64> = (0..m).map(|j| q_all[(i, j)]).collect();
546 let beta_signed: Vec<f64> = beta.iter().map(|&b| b * f64::from(y[i])).collect();
547 let new_gam = dp_alignment_core(&beta_signed, &qi, argvals, lambda);
548 for j in 0..m {
549 gammas[(i, j)] = new_gam[j];
550 }
551 }
552}
553
554fn compute_logistic_predictions(
556 aligned_srsfs: &FdMatrix,
557 beta: &[f64],
558 weights: &[f64],
559 alpha: f64,
560 y: &[i8],
561 lambda: f64,
562) -> (Vec<f64>, Vec<usize>, f64, f64) {
563 let n = y.len();
564 let eta = srsf_fitted_values(aligned_srsfs, beta, weights, alpha);
565 let probabilities: Vec<f64> = eta.iter().map(|&e| 1.0 / (1.0 + (-e).exp())).collect();
566 let predicted_classes: Vec<usize> = probabilities
567 .iter()
568 .map(|&p| usize::from(p >= 0.5))
569 .collect();
570 let accuracy = predicted_classes
571 .iter()
572 .zip(y.iter())
573 .filter(|(&p, &t)| p == usize::from(t == 1))
574 .count() as f64
575 / n as f64;
576 let loss = logistic_loss(&probabilities, y, beta, lambda);
577 (probabilities, predicted_classes, accuracy, loss)
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use crate::test_helpers::uniform_grid;
584
585 fn make_class_data(
588 n_per_class: usize,
589 k_classes: usize,
590 m: usize,
591 ) -> (FdMatrix, Vec<usize>, Vec<f64>) {
592 let argvals = uniform_grid(m);
593 let n = n_per_class * k_classes;
594 let mut data_col_major = vec![0.0f64; n * m];
595 let mut y = vec![0usize; n];
596
597 for cls in 0..k_classes {
598 let centre = (cls as f64 + 1.0) / (k_classes as f64 + 1.0);
600 let width = 0.08;
601 for obs in 0..n_per_class {
602 let row = cls * n_per_class + obs;
603 y[row] = cls;
604 let noise_seed = (row * 17 + 3) as f64 * 0.001;
606 for col in 0..m {
607 let t = argvals[col];
608 let val =
609 (-((t - centre) / width).powi(2)).exp() + noise_seed * (col as f64).sin();
610 data_col_major[row + col * n] = val;
612 }
613 }
614 }
615
616 let mat = FdMatrix::from_column_major(data_col_major, n, m).unwrap();
617 (mat, y, argvals)
618 }
619
620 #[test]
623 fn elastic_multinomial_shape_smoke() {
624 let (data, y, argvals) = make_class_data(2, 3, 20);
625 let n = data.nrows();
626
627 let result = elastic_multinomial(&data, &y, &argvals, 4, 0.01, 5, 1e-3)
628 .expect("elastic_multinomial should succeed on valid K=3 input");
629
630 assert_eq!(result.n_classes, 3, "n_classes must be 3");
631 assert_eq!(result.classes, vec![0, 1, 2], "classes must be [0,1,2]");
632 assert_eq!(result.class_models.len(), 3, "must have 3 OvR models");
633 assert_eq!(
634 result.train_probabilities.shape(),
635 (n, 3),
636 "train_probabilities must be (n, 3)"
637 );
638 for row_i in 0..n {
639 let row_sum: f64 = (0..3)
640 .map(|col| result.train_probabilities[(row_i, col)])
641 .sum();
642 assert!(
643 (row_sum - 1.0).abs() < 1e-9,
644 "row {} sum = {} (expected 1.0)",
645 row_i,
646 row_sum
647 );
648 }
649 assert_eq!(
650 result.predicted_classes.len(),
651 n,
652 "predicted_classes must have length n"
653 );
654 assert!(
655 (0.0..=1.0).contains(&result.train_accuracy),
656 "train_accuracy must be in [0,1]"
657 );
658 }
659
660 #[test]
663 fn elastic_multinomial_recovers_separated_classes() {
664 let (data, y, argvals) = make_class_data(3, 3, 24);
666
667 let result =
668 elastic_multinomial(&data, &y, &argvals, 4, 0.01, 8, 1e-3).expect("fit should succeed");
669
670 assert!(
672 result.train_accuracy >= 0.8,
673 "train_accuracy {} < 0.8 threshold",
674 result.train_accuracy
675 );
676
677 let n_per_class = 3usize;
679 let k = 3usize;
680 let m = 24;
681 let argvals2 = uniform_grid(m);
682 let mut new_col_major = vec![0.0f64; k * m];
683 let mut expected_labels = vec![0usize; k];
684 for cls in 0..k {
685 let centre = (cls as f64 + 1.0) / (k as f64 + 1.0);
686 let width = 0.08;
687 expected_labels[cls] = cls;
688 for col in 0..m {
689 let t = argvals2[col];
690 new_col_major[cls + col * k] = (-((t - centre) / width).powi(2)).exp();
691 }
692 }
693 let new_data = FdMatrix::from_column_major(new_col_major, k, m).unwrap();
694 let preds = predict_elastic_multinomial(&result, &new_data, &argvals2);
695 assert_eq!(preds.len(), k, "predict must return k labels");
696 for (i, (&pred, &exp)) in preds.iter().zip(expected_labels.iter()).enumerate() {
698 assert_eq!(
699 pred, exp,
700 "class {} template predicted as {} (expected {})",
701 i, pred, exp
702 );
703 }
704 let _ = n_per_class; }
706
707 #[test]
708 fn elastic_multinomial_k2_agrees_with_binary() {
709 let m = 20;
711 let argvals = uniform_grid(m);
712 let n_per = 3usize;
713 let n = n_per * 2;
714
715 let mut data_col = vec![0.0f64; n * m];
716 let mut y_multi = vec![0usize; n];
717 let mut y_bin = vec![0i8; n];
718
719 for obs in 0..n_per {
720 let centre = 0.25;
721 let w = 0.1;
722 y_multi[obs] = 0;
723 y_bin[obs] = -1;
724 for col in 0..m {
725 let t = argvals[col];
726 data_col[obs + col * n] = (-((t - centre) / w).powi(2)).exp();
727 }
728 }
729 for obs in 0..n_per {
730 let row = n_per + obs;
731 let centre = 0.75;
732 let w = 0.1;
733 y_multi[row] = 1;
734 y_bin[row] = 1;
735 for col in 0..m {
736 let t = argvals[col];
737 data_col[row + col * n] = (-((t - centre) / w).powi(2)).exp();
738 }
739 }
740 let data = FdMatrix::from_column_major(data_col, n, m).unwrap();
741
742 let ncomp_beta = 4;
743 let lambda = 0.01;
744 let max_iter = 8;
745 let tol = 1e-3;
746
747 let multi_fit =
748 elastic_multinomial(&data, &y_multi, &argvals, ncomp_beta, lambda, max_iter, tol)
749 .expect("multinomial K=2 should succeed");
750 let bin_fit = elastic_logistic(&data, &y_bin, &argvals, ncomp_beta, lambda, max_iter, tol)
751 .expect("binary logistic should succeed");
752
753 let bin_preds: Vec<usize> = bin_fit.predicted_classes.clone();
756
757 assert_eq!(
758 multi_fit.predicted_classes, bin_preds,
759 "K=2 multinomial predictions must agree with binary elastic_logistic"
760 );
761 }
762
763 #[test]
766 fn elastic_multinomial_rejects_count_mismatch() {
767 let (data, _, argvals) = make_class_data(2, 2, 10);
768 let bad_y: Vec<usize> = vec![0; data.nrows() - 1];
770 let result = elastic_multinomial(&data, &bad_y, &argvals, 4, 0.0, 5, 1e-3);
771 assert!(result.is_err(), "should return Err on y.len() != n");
772 }
773
774 #[test]
775 fn elastic_multinomial_rejects_single_class() {
776 let (data, _, argvals) = make_class_data(2, 2, 10);
777 let all_zero: Vec<usize> = vec![0; data.nrows()];
778 let result = elastic_multinomial(&data, &all_zero, &argvals, 4, 0.0, 5, 1e-3);
779 assert!(result.is_err(), "should return Err for K<2");
780 }
781
782 #[test]
783 fn elastic_multinomial_rejects_noncontiguous_labels() {
784 let (data, _, argvals) = make_class_data(2, 2, 10);
785 let mut bad_y: Vec<usize> = vec![0; data.nrows()];
787 bad_y[data.nrows() - 1] = 2;
788 bad_y[data.nrows() - 2] = 2;
789 let result = elastic_multinomial(&data, &bad_y, &argvals, 4, 0.0, 5, 1e-3);
790 assert!(
791 result.is_err(),
792 "should return Err for non-contiguous labels"
793 );
794 }
795
796 #[test]
797 fn elastic_multinomial_rejects_empty() {
798 let data = FdMatrix::zeros(0, 10);
799 let y: Vec<usize> = vec![];
800 let argvals = uniform_grid(10);
801 let result = elastic_multinomial(&data, &y, &argvals, 4, 0.0, 5, 1e-3);
802 assert!(result.is_err(), "should return Err for empty input");
803 }
804
805 #[test]
808 fn elastic_multinomial_rejects_m_lt_2() {
809 let data = FdMatrix::zeros(4, 1);
811 let y = vec![0usize, 0, 1, 1];
812 let argvals = vec![0.0];
813 let result = elastic_multinomial(&data, &y, &argvals, 4, 0.0, 5, 1e-3);
814 assert!(result.is_err(), "should return Err when m < 2");
815 }
816
817 #[test]
818 fn elastic_multinomial_rejects_argvals_mismatch() {
819 let (data, y, _) = make_class_data(2, 2, 10);
820 let bad_argvals = uniform_grid(5);
822 let result = elastic_multinomial(&data, &y, &bad_argvals, 4, 0.0, 5, 1e-3);
823 assert!(result.is_err(), "should return Err when argvals.len() != m");
824 }
825
826 #[test]
829 fn elastic_multinomial_near_zero_row_stays_finite() {
830 let (data, y, argvals) = make_class_data(2, 3, 20);
834 let result =
835 elastic_multinomial(&data, &y, &argvals, 4, 0.01, 5, 1e-3).expect("fit should succeed");
836 let (n, k) = result.train_probabilities.shape();
837 for row_i in 0..n {
838 for col in 0..k {
839 let v = result.train_probabilities[(row_i, col)];
840 assert!(
841 v.is_finite(),
842 "probability at ({},{}) is not finite: {}",
843 row_i,
844 col,
845 v
846 );
847 assert!(
848 v >= 0.0,
849 "probability at ({},{}) is negative: {}",
850 row_i,
851 col,
852 v
853 );
854 }
855 let row_sum: f64 = (0..k).map(|c| result.train_probabilities[(row_i, c)]).sum();
856 assert!(
857 (row_sum - 1.0).abs() < 1e-9,
858 "row {} sum={} not 1",
859 row_i,
860 row_sum
861 );
862 }
863 }
864
865 #[test]
868 fn predict_elastic_multinomial_empty_input_returns_empty() {
869 let (data, y, argvals) = make_class_data(2, 3, 20);
870 let result =
871 elastic_multinomial(&data, &y, &argvals, 4, 0.01, 5, 1e-3).expect("fit should succeed");
872 let empty_data = FdMatrix::zeros(0, 20);
873 let preds = predict_elastic_multinomial(&result, &empty_data, &argvals);
874 assert!(
875 preds.is_empty(),
876 "predict on 0-row input must return empty Vec"
877 );
878 let preds2 = result.predict(&empty_data, &argvals);
880 assert!(
881 preds2.is_empty(),
882 "predict() method on 0-row input must return empty Vec"
883 );
884 }
885}