Skip to main content

fdars_core/elastic_regression/
logistic.rs

1//! Elastic logistic regression for binary classification.
2
3use 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/// Result of elastic logistic regression.
12#[derive(Debug, Clone, PartialEq)]
13#[non_exhaustive]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct ElasticLogisticResult {
16    /// Intercept.
17    pub alpha: f64,
18    /// Regression function β(t), length m.
19    pub beta: Vec<f64>,
20    /// Predicted probabilities, length n.
21    pub probabilities: Vec<f64>,
22    /// Predicted class labels (0 or 1), length n.
23    pub predicted_classes: Vec<usize>,
24    /// Classification accuracy.
25    pub accuracy: f64,
26    /// Logistic loss.
27    pub loss: f64,
28    /// Final warping functions (n × m).
29    pub gammas: FdMatrix,
30    /// Aligned SRSFs (n × m).
31    pub aligned_srsfs: FdMatrix,
32    /// Number of iterations used.
33    pub n_iter: usize,
34}
35
36/// Elastic logistic regression for binary classification.
37///
38/// Labels should be -1 or 1. Uses gradient descent with Armijo line search.
39///
40/// # Arguments
41/// * `data` — Functional data (n × m)
42/// * `y` — Binary labels (-1 or 1), length n
43/// * `argvals` — Evaluation points (length m)
44/// * `ncomp_beta` — Number of B-spline basis functions for β
45/// * `lambda` — Roughness penalty on β
46/// * `max_iter` — Maximum iterations
47/// * `tol` — Convergence tolerance
48///
49/// # Errors
50///
51/// Returns [`crate::FdarError::InvalidDimension`] if `n < 2`, `m < 2`,
52/// `y.len() != n`, or `argvals.len() != m`.
53#[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    // Final predictions
128    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/// Elastic logistic regression using a configuration struct.
146///
147/// Equivalent to [`elastic_logistic`] but bundles method parameters in [`ElasticConfig`].
148#[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
166/// Predict probabilities for new data using a fitted elastic logistic model.
167///
168/// Transforms new curves to SRSFs and applies the fitted logistic
169/// coefficients to produce P(Y=1).
170///
171/// # Arguments
172/// * `fit` — A fitted [`ElasticLogisticResult`]
173/// * `new_data` — New functional data (n_new × m)
174/// * `argvals` — Evaluation points (length m)
175pub 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    /// Predict probabilities for new data. Delegates to [`predict_elastic_logistic`].
188    pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Vec<f64> {
189        predict_elastic_logistic(self, new_data, argvals)
190    }
191}
192
193// ─── Elastic Multinomial ─────────────────────────────────────────────────────
194
195/// Result of elastic multinomial logistic regression (one-vs-rest, K ≥ 2 classes).
196///
197/// Each field corresponds to the joint outcome of fitting K binary
198/// [`ElasticLogisticResult`] models — one per class — and then normalizing the
199/// per-class sigmoid outputs to produce class posteriors.
200///
201/// # Probability convention
202///
203/// `train_probabilities` is an *n × K* matrix whose row *i* is the row-normalised
204/// vector of OvR sigmoid scores, so each row sums to 1. Predicted labels are the
205/// per-row argmax mapped through `classes`.
206///
207/// # K = 2 agreement
208///
209/// When K = 2, the predicted labels agree with the binary [`elastic_logistic`] on
210/// separable data (the OvR construction reduces to the binary case).
211#[derive(Debug, Clone, PartialEq)]
212#[non_exhaustive]
213#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
214pub struct ElasticMultinomialResult {
215    /// Number of classes K.
216    pub n_classes: usize,
217    /// Sorted distinct class labels (always `0..K`).
218    pub classes: Vec<usize>,
219    /// One OvR binary model per class, length K.
220    pub class_models: Vec<ElasticLogisticResult>,
221    /// Row-normalised OvR probabilities (n × K); each row sums to 1.
222    pub train_probabilities: FdMatrix,
223    /// Predicted class labels for training data, length n.
224    pub predicted_classes: Vec<usize>,
225    /// Fraction of training curves correctly classified.
226    pub train_accuracy: f64,
227}
228
229/// Elastic multinomial logistic regression for K ≥ 2 classes (one-vs-rest).
230///
231/// Fits K binary [`elastic_logistic`] models — class *k* labelled +1, all other
232/// classes labelled −1 — using the existing SRSF/warping/IRLS machinery unchanged.
233/// Row-normalises the K sigmoid scores to obtain class posteriors.
234///
235/// Mirrors the binary signature: labels are `&[usize]` in `0..K` (contiguous).
236///
237/// # Arguments
238/// * `data` — Functional data (n × m)
239/// * `y` — Class labels in `0..K` (contiguous, length n)
240/// * `argvals` — Evaluation points (length m)
241/// * `ncomp_beta` — Number of B-spline basis functions for β per OvR model
242/// * `lambda` — Roughness penalty on β
243/// * `max_iter` — Maximum iterations per OvR binary fit
244/// * `tol` — Convergence tolerance
245///
246/// # Errors
247///
248/// Returns [`crate::FdarError::InvalidDimension`] if `n == 0` or `y.len() != n`.
249/// Returns [`crate::FdarError::InvalidParameter`] if fewer than 2 distinct classes
250/// or the label set is not the contiguous range `0..K`.
251#[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    // ── Input guards (T-27-01) ───────────────────────────────────────────────
264    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    // Labels must form contiguous 0..K
294    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    // ── Fit K binary OvR models ──────────────────────────────────────────────
307    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    // ── Build n×K probability matrix and row-normalise (T-27-02) ────────────
320    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    // Row-normalise so each row sums to 1; guard zero-sum row → uniform 1/K
327    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            // Degenerate row: assign uniform probability
331            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    // ── Predicted classes and accuracy ───────────────────────────────────────
343    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
375/// Predict class labels for new curves using a fitted elastic multinomial model.
376///
377/// For each class model, calls [`predict_elastic_logistic`] to obtain P(Y=1) for
378/// that class, assembles an *n\_new × K* matrix, row-normalises (same zero-guard
379/// as fitting), and returns the per-row argmax mapped through `fit.classes`.
380///
381/// When `new_data` has zero rows (`n_new == 0`), returns an empty `Vec<usize>`
382/// immediately without error. This is intentionally permissive — the analogous
383/// training function [`elastic_multinomial`] returns an error for `n == 0` because
384/// fitting requires data; prediction on empty input is a valid no-op.
385///
386/// # Arguments
387/// * `fit` — A fitted [`ElasticMultinomialResult`]
388/// * `new_data` — New functional data (n\_new × m)
389/// * `argvals` — Evaluation points (length m), same grid used for fitting
390pub 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    // Collect OvR probabilities column by column
402    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    // Row-normalise
411    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    // Argmax → class label
426    (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    /// Predict class labels for new data. Delegates to [`predict_elastic_multinomial`].
444    pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Vec<usize> {
445        predict_elastic_multinomial(self, new_data, argvals)
446    }
447}
448
449// ─── Internal helpers ───────────────────────────────────────────────────────
450
451/// Compute logistic loss with L2 penalty.
452fn 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    // L2 penalty
462    loss += 0.5 * lambda * beta.iter().map(|&b| b * b).sum::<f64>();
463    loss
464}
465
466/// Compute logistic gradients for α and β, returning (grad_a, grad_beta, probabilities).
467fn 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
499/// Armijo line search for logistic regression. Returns optimal step size.
500fn 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
534/// Update warping functions for all curves in elastic logistic regression.
535fn 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
554/// Compute final logistic predictions: probabilities, classes, accuracy, loss.
555fn 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    /// Build a synthetic FdMatrix where each class has a bump centred at a distinct location.
586    /// Rows 0..n_per_class → class 0, next n_per_class rows → class 1, etc.
587    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            // Bump centre at a different location for each class
599            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                // tiny per-obs noise to avoid identical curves (scale: 0.03)
605                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                    // column-major: index = row + col * n
611                    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    // ── Task 1: shape smoke ───────────────────────────────────────────────────
621
622    #[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    // ── Task 2: predict + recovery + K=2 binary agreement ────────────────────
661
662    #[test]
663    fn elastic_multinomial_recovers_separated_classes() {
664        // K=3, well-separated bumps, modest size for speed
665        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        // Train accuracy threshold: >= 0.8 (documented)
671        assert!(
672            result.train_accuracy >= 0.8,
673            "train_accuracy {} < 0.8 threshold",
674            result.train_accuracy
675        );
676
677        // Predict on held-out template curves (one per class, no noise)
678        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        // Each template should recover its class
697        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; // suppress unused warning
705    }
706
707    #[test]
708    fn elastic_multinomial_k2_agrees_with_binary() {
709        // Well-separated 2-class data: class 0 has bump near 0.25, class 1 near 0.75
710        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        // Map binary predicted (0=class0 if p<0.5, 1=class1 if p>=0.5) to usize labels
754        // binary predicted_classes: 0 → y==-1 (class 0), 1 → y==1 (class 1)
755        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    // ── Task 3: input guards ──────────────────────────────────────────────────
764
765    #[test]
766    fn elastic_multinomial_rejects_count_mismatch() {
767        let (data, _, argvals) = make_class_data(2, 2, 10);
768        // y has 1 fewer element than n
769        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        // Labels {0, 2} — gap at 1
786        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    // ── WR-01: m < 2 and argvals mismatch guards ─────────────────────────────
806
807    #[test]
808    fn elastic_multinomial_rejects_m_lt_2() {
809        // m=1 column matrix
810        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        // argvals has wrong length
821        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    // ── CR-02 regression: near-zero-probability row → finite uniform output ──
827
828    #[test]
829    fn elastic_multinomial_near_zero_row_stays_finite() {
830        // Build a result where train_probabilities has an all-near-zero row
831        // by constructing one directly and exercising the normalization path
832        // indirectly through a real fit, then assert all values are finite.
833        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    // ── WR-03: predict on zero-row input returns empty vec ────────────────────
866
867    #[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        // Also test via the impl method (WR-02)
879        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}