Skip to main content

greeners_core/
multivariate.rs

1use crate::error::GreenersError;
2use crate::linalg::{LinalgEigh as _, LinalgInverse as _, UPLO};
3use ndarray::{s, Array1, Array2, Axis};
4use statrs::distribution::{ContinuousCDF, FisherSnedecor};
5use std::fmt;
6
7// ─── PCA ───────────────────────────────────────────────────────────────────────
8
9/// Result of Principal Component Analysis.
10#[derive(Debug)]
11pub struct PCAResult {
12    /// Principal components (eigenvectors as columns, k x n_components)
13    pub components: Array2<f64>,
14    /// Explained variance per component
15    pub explained_variance: Array1<f64>,
16    /// Proportion of variance explained
17    pub explained_variance_ratio: Array1<f64>,
18    /// Loadings (components scaled by sqrt of eigenvalue)
19    pub loadings: Array2<f64>,
20    /// Scores (data projected onto components)
21    pub scores: Array2<f64>,
22    /// Column means (for centering)
23    pub mean: Array1<f64>,
24    /// Column standard deviations (for standardizing)
25    pub std: Array1<f64>,
26    pub n_obs: usize,
27    pub n_components: usize,
28}
29
30impl PCAResult {
31    /// Project new data onto principal components.
32    pub fn transform(&self, data: &Array2<f64>) -> Array2<f64> {
33        let centered = self.standardize(data);
34        centered.dot(&self.components)
35    }
36
37    /// Reconstruct data from scores.
38    pub fn inverse_transform(&self, scores: &Array2<f64>) -> Array2<f64> {
39        let recon = scores.dot(&self.components.t());
40        // Unstandardize
41        let mut result = recon;
42        for (j, mut col) in result.axis_iter_mut(Axis(1)).enumerate() {
43            col *= self.std[j];
44            col += self.mean[j];
45        }
46        result
47    }
48
49    fn standardize(&self, data: &Array2<f64>) -> Array2<f64> {
50        let mut centered = data.clone();
51        for (j, mut col) in centered.axis_iter_mut(Axis(1)).enumerate() {
52            col -= self.mean[j];
53            if self.std[j] > 1e-15 {
54                col /= self.std[j];
55            }
56        }
57        centered
58    }
59}
60
61impl fmt::Display for PCAResult {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        writeln!(f, "\n{:=^60}", " Principal Component Analysis ")?;
64        writeln!(f, "{:<20} {:>10}", "Observations:", self.n_obs)?;
65        writeln!(f, "{:<20} {:>10}", "Components:", self.n_components)?;
66        writeln!(
67            f,
68            "\n{:<12} {:>12} {:>12}",
69            "Component", "Var Expl", "Cumulative"
70        )?;
71        writeln!(f, "{:-^40}", "")?;
72        let mut cum = 0.0;
73        for i in 0..self.n_components {
74            cum += self.explained_variance_ratio[i];
75            writeln!(
76                f,
77                "PC{:<10} {:>12.4} {:>12.4}",
78                i + 1,
79                self.explained_variance_ratio[i],
80                cum
81            )?;
82        }
83        writeln!(f, "{:=^60}", "")
84    }
85}
86
87/// Principal Component Analysis via eigendecomposition of correlation matrix.
88pub struct PCA;
89
90impl PCA {
91    pub fn fit(data: &Array2<f64>, n_components: usize) -> Result<PCAResult, GreenersError> {
92        let (n, p) = (data.nrows(), data.ncols());
93        if n < 2 {
94            return Err(GreenersError::InvalidOperation(
95                "Need at least 2 observations for PCA".into(),
96            ));
97        }
98        let nc = n_components.min(p);
99
100        // Standardize
101        let mut mean = Array1::<f64>::zeros(p);
102        let mut std = Array1::<f64>::zeros(p);
103        for j in 0..p {
104            let col = data.column(j);
105            mean[j] = col.mean().unwrap_or(0.0);
106            let var = col.iter().map(|x| (x - mean[j]).powi(2)).sum::<f64>() / (n - 1) as f64;
107            std[j] = var.sqrt().max(1e-15);
108        }
109
110        let mut z = data.clone();
111        for (j, mut col) in z.axis_iter_mut(Axis(1)).enumerate() {
112            col -= mean[j];
113            col /= std[j];
114        }
115
116        // Correlation matrix = Z'Z / (n-1)
117        let corr = z.t().dot(&z) / (n - 1) as f64;
118
119        // Eigendecomposition (returns ascending order)
120        let (eigenvalues, eigenvectors) = corr.eigh(UPLO::Upper)?;
121
122        // Reverse to descending order
123        let total_var: f64 = eigenvalues.iter().sum();
124        let ev: Array1<f64> = eigenvalues.slice(s![..;-1]).to_owned();
125        let evec: Array2<f64> = eigenvectors.slice(s![.., ..;-1]).to_owned();
126
127        // Take top n_components
128        let explained_variance = ev.slice(s![..nc]).to_owned();
129        let explained_variance_ratio = explained_variance.mapv(|v| v / total_var.max(1e-15));
130        let components = evec.slice(s![.., ..nc]).to_owned();
131
132        // Loadings = components * sqrt(eigenvalue)
133        let mut loadings = components.clone();
134        for (j, mut col) in loadings.axis_iter_mut(Axis(1)).enumerate() {
135            col *= explained_variance[j].sqrt();
136        }
137
138        // Scores
139        let scores = z.dot(&components);
140
141        Ok(PCAResult {
142            components,
143            explained_variance,
144            explained_variance_ratio,
145            loadings,
146            scores,
147            mean,
148            std,
149            n_obs: n,
150            n_components: nc,
151        })
152    }
153}
154
155// ─── Factor Analysis ───────────────────────────────────────────────────────────
156
157/// Rotation method for Factor Analysis.
158#[derive(Debug, Clone)]
159pub enum Rotation {
160    None,
161    Varimax,
162}
163
164/// Result of Factor Analysis.
165#[derive(Debug)]
166pub struct FactorResult {
167    pub loadings: Array2<f64>,
168    pub communalities: Array1<f64>,
169    pub uniquenesses: Array1<f64>,
170    pub eigenvalues: Array1<f64>,
171    pub n_factors: usize,
172    pub n_obs: usize,
173}
174
175impl fmt::Display for FactorResult {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        writeln!(f, "\n{:=^60}", " Factor Analysis ")?;
178        writeln!(f, "{:<20} {:>10}", "Observations:", self.n_obs)?;
179        writeln!(f, "{:<20} {:>10}", "Factors:", self.n_factors)?;
180        writeln!(f, "\nCommunalities:")?;
181        for (i, &c) in self.communalities.iter().enumerate() {
182            writeln!(f, "  Var{}: {:.4}", i + 1, c)?;
183        }
184        writeln!(f, "{:=^60}", "")
185    }
186}
187
188/// Factor Analysis via principal axis factoring.
189pub struct FactorAnalysis;
190
191impl FactorAnalysis {
192    pub fn fit(
193        data: &Array2<f64>,
194        n_factors: usize,
195        rotation: Rotation,
196    ) -> Result<FactorResult, GreenersError> {
197        let (n, p) = (data.nrows(), data.ncols());
198        if n < 2 || n_factors > p {
199            return Err(GreenersError::InvalidOperation(
200                "Invalid dimensions for factor analysis".into(),
201            ));
202        }
203
204        // Standardize
205        let mut z = data.clone();
206        for mut col in z.axis_iter_mut(Axis(1)) {
207            let m = col.mean().unwrap_or(0.0);
208            col -= m;
209            let s = col.iter().map(|x| x * x).sum::<f64>() / (n - 1) as f64;
210            let s = s.sqrt().max(1e-15);
211            col /= s;
212        }
213
214        let corr = z.t().dot(&z) / (n - 1) as f64;
215        let (eigenvalues_all, eigenvectors_all) = corr.eigh(UPLO::Upper)?;
216
217        // Reverse to descending
218        let eigenvalues: Array1<f64> = eigenvalues_all.slice(s![..;-1]).to_owned();
219        let eigenvectors: Array2<f64> = eigenvectors_all.slice(s![.., ..;-1]).to_owned();
220
221        // Initial loadings: L = V * sqrt(Lambda)
222        let mut loadings = Array2::<f64>::zeros((p, n_factors));
223        for j in 0..n_factors {
224            let sqrt_ev = eigenvalues[j].max(0.0).sqrt();
225            for i in 0..p {
226                loadings[[i, j]] = eigenvectors[[i, j]] * sqrt_ev;
227            }
228        }
229
230        // Varimax rotation
231        if matches!(rotation, Rotation::Varimax) {
232            loadings = varimax_rotation(&loadings, 100);
233        }
234
235        // Communalities: sum of squared loadings per variable
236        let communalities: Array1<f64> = (0..p)
237            .map(|i| (0..n_factors).map(|j| loadings[[i, j]].powi(2)).sum())
238            .collect::<Vec<_>>()
239            .into();
240
241        let uniquenesses = communalities.mapv(|c| (1.0 - c).max(0.0));
242
243        Ok(FactorResult {
244            loadings,
245            communalities,
246            uniquenesses,
247            eigenvalues: eigenvalues.slice(s![..n_factors]).to_owned(),
248            n_factors,
249            n_obs: n,
250        })
251    }
252}
253
254fn varimax_rotation(loadings: &Array2<f64>, max_iter: usize) -> Array2<f64> {
255    let (p, k) = (loadings.nrows(), loadings.ncols());
256    if k < 2 {
257        return loadings.clone();
258    }
259
260    let mut rotated = loadings.clone();
261
262    for _ in 0..max_iter {
263        let mut changed = false;
264        for i in 0..k {
265            for j in (i + 1)..k {
266                // Compute rotation angle for columns i and j
267                let mut a = 0.0;
268                let mut b = 0.0;
269                let mut c = 0.0;
270                let mut d = 0.0;
271
272                for r in 0..p {
273                    let li = rotated[[r, i]];
274                    let lj = rotated[[r, j]];
275                    let u = li * li - lj * lj;
276                    let v = 2.0 * li * lj;
277                    a += u;
278                    b += v;
279                    c += u * u - v * v;
280                    d += 2.0 * u * v;
281                }
282
283                let num = d - 2.0 * a * b / p as f64;
284                let den = c - (a * a - b * b) / p as f64;
285                let angle = 0.25 * num.atan2(den);
286
287                if angle.abs() < 1e-10 {
288                    continue;
289                }
290                changed = true;
291
292                let cos_a = angle.cos();
293                let sin_a = angle.sin();
294
295                for r in 0..p {
296                    let li = rotated[[r, i]];
297                    let lj = rotated[[r, j]];
298                    rotated[[r, i]] = cos_a * li + sin_a * lj;
299                    rotated[[r, j]] = -sin_a * li + cos_a * lj;
300                }
301            }
302        }
303        if !changed {
304            break;
305        }
306    }
307
308    rotated
309}
310
311// ─── MANOVA ────────────────────────────────────────────────────────────────────
312
313/// Result of MANOVA test.
314#[derive(Debug)]
315pub struct ManovaResult {
316    /// Wilks' Lambda
317    pub wilks_lambda: f64,
318    /// Pillai's trace
319    pub pillai_trace: f64,
320    /// Hotelling-Lawley trace
321    pub hotelling_lawley: f64,
322    /// Roy's largest root
323    pub roys_largest_root: f64,
324    /// Approximate F-values for each test
325    pub f_values: [f64; 4],
326    /// P-values for each test
327    pub p_values: [f64; 4],
328    pub n_obs: usize,
329    pub n_groups: usize,
330    pub n_vars: usize,
331}
332
333impl fmt::Display for ManovaResult {
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        writeln!(f, "\n{:=^70}", " One-Way MANOVA ")?;
336        writeln!(f, "{:<20} {:>10}", "Observations:", self.n_obs)?;
337        writeln!(f, "{:<20} {:>10}", "Groups:", self.n_groups)?;
338        writeln!(f, "{:<20} {:>10}", "Variables:", self.n_vars)?;
339        writeln!(
340            f,
341            "\n{:<24} {:>10} {:>10} {:>10}",
342            "Test", "Statistic", "F", "P-value"
343        )?;
344        writeln!(f, "{:-^60}", "")?;
345        let names = [
346            "Wilks' Lambda",
347            "Pillai's trace",
348            "Hotelling-Lawley",
349            "Roy's largest root",
350        ];
351        let stats = [
352            self.wilks_lambda,
353            self.pillai_trace,
354            self.hotelling_lawley,
355            self.roys_largest_root,
356        ];
357        for i in 0..4 {
358            writeln!(
359                f,
360                "{:<24} {:>10.4} {:>10.4} {:>10.4}",
361                names[i], stats[i], self.f_values[i], self.p_values[i]
362            )?;
363        }
364        writeln!(f, "{:=^70}", "")
365    }
366}
367
368/// One-way MANOVA.
369pub struct MANOVA;
370
371impl MANOVA {
372    /// Fit one-way MANOVA.
373    /// y_matrix: n x p matrix of dependent variables
374    /// groups: group assignment for each observation (length n)
375    pub fn fit(
376        y_matrix: &Array2<f64>,
377        groups: &Array1<usize>,
378    ) -> Result<ManovaResult, GreenersError> {
379        let (n, p) = (y_matrix.nrows(), y_matrix.ncols());
380        if n != groups.len() {
381            return Err(GreenersError::ShapeMismatch(
382                "y_matrix rows must match groups length".into(),
383            ));
384        }
385
386        // Find unique groups
387        let mut unique_groups: Vec<usize> = groups.iter().cloned().collect();
388        unique_groups.sort();
389        unique_groups.dedup();
390        let g = unique_groups.len();
391
392        if g < 2 {
393            return Err(GreenersError::InvalidOperation(
394                "Need at least 2 groups for MANOVA".into(),
395            ));
396        }
397
398        // Grand mean
399        let grand_mean: Array1<f64> = y_matrix.mean_axis(Axis(0)).ok_or_else(|| {
400            GreenersError::InvalidOperation("Cannot compute grand mean".to_string())
401        })?;
402
403        //Between groups (H) and within groups (E) matrixes
404        let mut h_matrix = Array2::<f64>::zeros((p, p));
405        let mut e_matrix = Array2::<f64>::zeros((p, p));
406
407        for &grp in &unique_groups {
408            // Indices for this group
409            let idx: Vec<usize> = (0..n).filter(|&i| groups[i] == grp).collect();
410            let ni = idx.len();
411            if ni == 0 {
412                continue;
413            }
414
415            // Group mean
416            let mut group_mean = Array1::<f64>::zeros(p);
417            for &i in &idx {
418                group_mean = &group_mean + &y_matrix.row(i).to_owned();
419            }
420            group_mean /= ni as f64;
421
422            // H += n_i * (mean_i - grand_mean)(mean_i - grand_mean)'
423            let diff = &group_mean - &grand_mean;
424            for a in 0..p {
425                for b in 0..p {
426                    h_matrix[[a, b]] += ni as f64 * diff[a] * diff[b];
427                }
428            }
429
430            // E += sum_j (y_ij - mean_i)(y_ij - mean_i)'
431            for &i in &idx {
432                let d = &y_matrix.row(i).to_owned() - &group_mean;
433                for a in 0..p {
434                    for b in 0..p {
435                        e_matrix[[a, b]] += d[a] * d[b];
436                    }
437                }
438            }
439        }
440
441        // Eigenvalues of E^{-1} H
442        let e_inv = e_matrix.inv()?;
443        let m = e_inv.dot(&h_matrix);
444        // Use symmetric eigendecomposition on (E^-1 H + H E^-1)/2 for stability
445        let m_sym = (&m + &m.t()) * 0.5;
446        let (eig_vals, _) = m_sym.eigh(UPLO::Upper)?;
447        let mut lambdas: Vec<f64> = eig_vals.iter().cloned().collect();
448        lambdas.sort_by(|a, b| b.total_cmp(a));
449        let s = p.min(g - 1);
450
451        // Statistics
452        let wilks_lambda: f64 = lambdas.iter().take(s).map(|&l| 1.0 / (1.0 + l)).product();
453        let pillai_trace: f64 = lambdas.iter().take(s).map(|&l| l / (1.0 + l)).sum();
454        let hotelling_lawley: f64 = lambdas.iter().take(s).sum();
455        let roys_largest_root = lambdas.first().cloned().unwrap_or(0.0);
456
457        // Approximate F-statistics
458        let df_h = (g - 1) as f64;
459        let df_e = (n - g) as f64;
460        let pf = p as f64;
461
462        // Wilks' Lambda F-approximation (Rao's F)
463        let t = if pf * pf + df_h * df_h - 5.0 > 0.0 {
464            ((pf * pf * df_h * df_h - 4.0) / (pf * pf + df_h * df_h - 5.0)).sqrt()
465        } else {
466            1.0
467        };
468        let df1_wilks = pf * df_h;
469        let df2_wilks = (df_e + df_h - 0.5 * (pf + df_h + 1.0)) * t - 0.5 * (df1_wilks) + 1.0;
470        let lambda_t = if t > 0.0 {
471            wilks_lambda.powf(1.0 / t)
472        } else {
473            wilks_lambda
474        };
475        let f_wilks = if lambda_t < 1.0 {
476            ((1.0 - lambda_t) / lambda_t) * (df2_wilks / df1_wilks)
477        } else {
478            0.0
479        };
480
481        // Pillai F-approximation
482        let s_f = s as f64;
483        let f_pillai = (pillai_trace / s_f)
484            * ((df_e + s_f - pf + s_f * df_h) / ((s_f.max(1.0)) * (s_f * df_h)));
485        let df1_pillai = s_f * pf * df_h / s_f.max(1.0);
486        let df2_pillai = s_f * (df_e + s_f - pf);
487
488        // Hotelling-Lawley F-approximation
489        let f_hl = hotelling_lawley * df_e / (s_f * df_h * pf);
490        let df1_hl = s_f * pf * df_h / s_f.max(1.0);
491        let df2_hl = s_f * df_e;
492
493        // Roy's largest root F-approximation
494        let f_roy = roys_largest_root * df_e / pf.max(df_h);
495        let df1_roy = pf.max(df_h);
496        let df2_roy = df_e;
497
498        let f_values = [f_wilks, f_pillai.max(0.0), f_hl.max(0.0), f_roy.max(0.0)];
499
500        // P-values
501        let p_values = [
502            f_pvalue(f_wilks, df1_wilks, df2_wilks),
503            f_pvalue(f_pillai, df1_pillai, df2_pillai),
504            f_pvalue(f_hl, df1_hl, df2_hl),
505            f_pvalue(f_roy, df1_roy, df2_roy),
506        ];
507
508        Ok(ManovaResult {
509            wilks_lambda,
510            pillai_trace,
511            hotelling_lawley,
512            roys_largest_root,
513            f_values,
514            p_values,
515            n_obs: n,
516            n_groups: g,
517            n_vars: p,
518        })
519    }
520}
521
522// ─── Canonical Correlation Analysis ──────────────────────────────────────────
523
524/// Result of Canonical Correlation Analysis.
525#[derive(Debug)]
526pub struct CanCorrResult {
527    /// Canonical correlations (descending)
528    pub cancorr: Array1<f64>,
529    /// Weights for X variables
530    pub x_weights: Array2<f64>,
531    /// Weights for Y variables
532    pub y_weights: Array2<f64>,
533    /// Wilks' Lambda
534    pub wilks_lambda: f64,
535    /// Approximate F-statistic
536    pub f_stat: f64,
537    /// P-value
538    pub p_value: f64,
539    pub n_obs: usize,
540}
541
542impl fmt::Display for CanCorrResult {
543    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
544        writeln!(f, "\n{:=^60}", " Canonical Correlation Analysis ")?;
545        writeln!(f, "{:<20} {:>10}", "Observations:", self.n_obs)?;
546        writeln!(f, "{:<20} {:>10.4}", "Wilks' Lambda:", self.wilks_lambda)?;
547        writeln!(f, "{:<20} {:>10.4}", "F-statistic:", self.f_stat)?;
548        writeln!(f, "{:<20} {:>10.4}", "P-value:", self.p_value)?;
549        writeln!(f, "\nCanonical Correlations:")?;
550        for (i, &c) in self.cancorr.iter().enumerate() {
551            writeln!(f, "  CC{}: {:.4}", i + 1, c)?;
552        }
553        writeln!(f, "{:=^60}", "")
554    }
555}
556
557/// Canonical Correlation Analysis.
558pub struct CanCorr;
559
560impl CanCorr {
561    /// Fit CCA.
562    ///
563    /// - `x`: n x p matrix
564    /// - `y`: n x q matrix
565    pub fn fit(x: &Array2<f64>, y: &Array2<f64>) -> Result<CanCorrResult, GreenersError> {
566        let (n, p) = (x.nrows(), x.ncols());
567        let q = y.ncols();
568
569        if n != y.nrows() {
570            return Err(GreenersError::ShapeMismatch(
571                "x and y must have same number of rows".into(),
572            ));
573        }
574        if n < p + q + 1 {
575            return Err(GreenersError::ShapeMismatch(
576                "Not enough observations for CCA".into(),
577            ));
578        }
579
580        let s = p.min(q);
581
582        // Center
583        let x_mean = x
584            .mean_axis(Axis(0))
585            .ok_or_else(|| GreenersError::InvalidOperation("Cannot compute X mean".to_string()))?;
586        let y_mean = y
587            .mean_axis(Axis(0))
588            .ok_or_else(|| GreenersError::InvalidOperation("Cannot compute Y mean".to_string()))?;
589        let mut xc = x.clone();
590        let mut yc = y.clone();
591        for (j, mut col) in xc.axis_iter_mut(Axis(1)).enumerate() {
592            col -= x_mean[j];
593        }
594        for (j, mut col) in yc.axis_iter_mut(Axis(1)).enumerate() {
595            col -= y_mean[j];
596        }
597
598        let nf = (n - 1) as f64;
599        let sxx = xc.t().dot(&xc) / nf;
600        let syy = yc.t().dot(&yc) / nf;
601        let sxy = xc.t().dot(&yc) / nf;
602
603        // Compute Sxx^{-1/2} via eigendecomposition
604        let sxx_inv = sxx.inv()?;
605        let syy_inv = syy.inv()?;
606
607        // Eigenvalue problem: Sxx^{-1} Sxy Syy^{-1} Syx a = lambda^2 a
608        let m = sxx_inv.dot(&sxy).dot(&syy_inv).dot(&sxy.t());
609        let m_sym = (&m + &m.t()) * 0.5;
610        let (eig_vals, eig_vecs) = m_sym.eigh(UPLO::Upper)?;
611
612        // Sort descending
613        let mut idx: Vec<usize> = (0..p).collect();
614        idx.sort_by(|&a, &b| eig_vals[b].total_cmp(&eig_vals[a]));
615
616        let cancorr: Array1<f64> = idx
617            .iter()
618            .take(s)
619            .map(|&i| eig_vals[i].max(0.0).sqrt().min(1.0))
620            .collect();
621
622        let mut x_weights = Array2::<f64>::zeros((p, s));
623        for (new_col, &old_col) in idx.iter().take(s).enumerate() {
624            x_weights
625                .column_mut(new_col)
626                .assign(&eig_vecs.column(old_col));
627        }
628
629        // Y weights: Syy^{-1} Syx * x_weights, normalized
630        let y_weights_raw = syy_inv.dot(&sxy.t()).dot(&x_weights);
631        let mut y_weights = Array2::<f64>::zeros((q, s));
632        for j in 0..s {
633            let col = y_weights_raw.column(j);
634            let norm = col.dot(&col).sqrt().max(1e-15);
635            y_weights.column_mut(j).assign(&(&col / norm));
636        }
637
638        // Wilks' Lambda = product(1 - r_i^2)
639        let wilks_lambda: f64 = cancorr.iter().map(|&r| 1.0 - r * r).product();
640
641        // Approximate F-test (Rao's F)
642        let pf = p as f64;
643        let qf = q as f64;
644        let nf_obs = n as f64;
645        let t = if pf * pf * qf * qf - 4.0 > 0.0 {
646            ((pf * pf * qf * qf - 4.0) / (pf * pf + qf * qf - 5.0)).sqrt()
647        } else {
648            1.0
649        };
650        let df1 = pf * qf;
651        let df2 = ((nf_obs - 1.0 - 0.5 * (pf + qf + 1.0)) * t - 0.5 * df1 + 1.0).max(1.0);
652        let lambda_t = if t > 0.0 {
653            wilks_lambda.powf(1.0 / t)
654        } else {
655            wilks_lambda
656        };
657        let f_stat = if lambda_t < 1.0 {
658            ((1.0 - lambda_t) / lambda_t) * (df2 / df1)
659        } else {
660            0.0
661        };
662
663        let p_value = f_pvalue(f_stat, df1, df2);
664
665        Ok(CanCorrResult {
666            cancorr,
667            x_weights,
668            y_weights,
669            wilks_lambda,
670            f_stat,
671            p_value,
672            n_obs: n,
673        })
674    }
675}
676
677fn f_pvalue(f: f64, df1: f64, df2: f64) -> f64 {
678    if df1 <= 0.0 || df2 <= 0.0 || !f.is_finite() || f <= 0.0 {
679        return 1.0;
680    }
681    match FisherSnedecor::new(df1, df2) {
682        Ok(dist) => 1.0 - dist.cdf(f),
683        Err(_) => 1.0,
684    }
685}