Skip to main content

ferrolearn_decomp/
kernel_pca.rs

1//! Kernel Principal Component Analysis (Kernel PCA).
2//!
3//! [`KernelPCA`] performs non-linear dimensionality reduction by first mapping
4//! data into a higher-dimensional (possibly infinite-dimensional) feature space
5//! via a kernel function, then performing standard PCA in that space.
6//!
7//! # Kernels
8//!
9//! - **Linear**: `K(x, y) = x . y` (equivalent to standard PCA)
10//! - **RBF** (Gaussian): `K(x, y) = exp(-gamma * ||x - y||^2)`
11//! - **Polynomial**: `K(x, y) = (gamma * x . y + coef0)^degree`
12//! - **Sigmoid**: `K(x, y) = tanh(gamma * x . y + coef0)`
13//!
14//! # Algorithm
15//!
16//! 1. Compute the kernel matrix `K` of shape `(n_samples, n_samples)`.
17//! 2. Centre `K` in feature space: `K_c = K - 1_n K - K 1_n + 1_n K 1_n`
18//!    where `1_n` is the `(n, n)` matrix with all entries `1/n`.
19//! 3. Eigendecompose `K_c` using the Jacobi iterative method.
20//! 4. Sort eigenvalues descending and retain the top `n_components`.
21//! 5. Scale eigenvectors by `1 / sqrt(eigenvalue)`.
22//!
23//! # Examples
24//!
25//! ```
26//! use ferrolearn_decomp::{KernelPCA, Kernel};
27//! use ferrolearn_core::traits::{Fit, Transform};
28//! use ndarray::array;
29//!
30//! let kpca = KernelPCA::<f64>::new(2).with_kernel(Kernel::RBF);
31//! let x = array![
32//!     [1.0, 2.0],
33//!     [3.0, 4.0],
34//!     [5.0, 6.0],
35//!     [7.0, 8.0],
36//!     [9.0, 10.0],
37//! ];
38//! let fitted = kpca.fit(&x, &()).unwrap();
39//! let projected = fitted.transform(&x).unwrap();
40//! assert_eq!(projected.ncols(), 2);
41//! ```
42//!
43//! ## REQ status
44//!
45//! Design: `.design/decomp/kernel_pca.md`. Tracking: #1561. Each REQ is BINARY —
46//! SHIPPED (impl + non-test consumer + tests + green verification) or NOT-STARTED
47//! (concrete open blocker). Non-test consumers: crate re-export (`lib.rs:90`), the
48//! PyO3 `_RsKernelPCA` binding (`ferrolearn-python/src/extras.rs:1122`), and
49//! `PipelineTransformer`. Oracle = live sklearn 1.5.2 (`_kernel_pca.py`,
50//! `eigen_solver='dense'` for the deterministic value oracle), run from `/tmp`
51//! (R-CHAR-3). The centered-kernel eigendecomposition is deterministic, so the
52//! embedding has full value parity with sklearn (incl. sign) for distinct
53//! eigenvalues.
54//!
55//! | REQ | Scope | Status | Evidence / Blocker |
56//! |---|---|---|---|
57//! | REQ-1 | 4 kernels (Linear/RBF/Polynomial/Sigmoid) kernel values | SHIPPED | `kernel_value` (`kernel_pca.rs:222`); in-module per-kernel tests |
58//! | REQ-2 | KernelCenterer double-centering (train + test gram) | SHIPPED | `centre_kernel_matrix` (`:299`) + transform re-centering (`:579-592`) = sklearn `KernelCenterer` |
59//! | REQ-3 | `svd_flip` sign convention + embedding value parity | SHIPPED | `fit` applies per-COLUMN max-abs-row-positive flip on `alphas` (`:523-545` = sklearn `svd_flip(u=eigenvectors_, v=None)` `_kernel_pca.py:373`); matches sklearn dense incl. sign across all 4 kernels (linear 5.8e-13, rbf 3.4e-13, poly 2.2e-15, sigmoid 3.3e-14). Was #1562, fixed. Tests `divergence_svd_flip_*` |
60//! | REQ-4 | `coef0` default = 1 | SHIPPED | `KernelPCA::new` `coef0: 1.0` (`:102` = sklearn `_kernel_pca.py:289`). Was #1563, fixed. Test `divergence_coef0_default` |
61//! | REQ-5 | eigenvalues non-negative | SHIPPED | clamp negatives to 0 (`:504-509`); `test_kernel_pca_eigenvalues_non_negative` |
62//! | REQ-6 | eigenvalues sorted descending | SHIPPED | sort (`:491-496`); `test_kernel_pca_eigenvalues_sorted_descending` |
63//! | REQ-7 | embedding shape + `1/sqrt(eigval)` scaling | SHIPPED | `alphas = eigvec/sqrt(eigval)` (`:511-520`); shape tests |
64//! | REQ-8 | transform of NEW data (train-kernel centering) | SHIPPED | `transform` (`:550`); `test_kernel_pca_transform_new_data` |
65//! | REQ-9 | auto-gamma `1/n_features` | SHIPPED | `effective_gamma = gamma or 1/n_features` (`:465`); `test_kernel_pca_auto_gamma` |
66//! | REQ-10 | Error/parameter contracts (incl. NON-FINITE rejection) | SHIPPED (scoped) | `fit`/`transform` guards (n_components 0, n_samples<2, feature mismatch). **n_components > n_samples CLAMPS** to `min(n_samples, n_components)` and fits = sklearn `_kernel_pca.py:337` (was #2389, fixed; the `n_comp_effective` binding in `fit`); pin `tests/divergence_kernel_pca_2389.rs::divergence_n_components_exceeds_samples_clamps`. **NON-PSD centered kernel RAISES**: a significant negative eigenvalue (`min_eig < -1e-5*max_eig` AND `< -1e-10`) → `InvalidParameter{name:"kernel", reason:"... not positive semi-definite ..."}` = sklearn `_check_psd_eigenvalues` (`_kernel_pca.py:368`, `utils/validation.py:1942-1963`); tiny negatives still clip to 0 (was #2390, fixed; the PSD check in `fit`); pin `tests/divergence_kernel_pca_2389.rs::divergence_non_psd_kernel_raises`. NON-FINITE: `fit`+`transform` call `reject_non_finite` (`kernel_pca.rs` symbol `reject_non_finite`) BEFORE the kernel matrix/projection, returning `InvalidParameter{name:"X", reason:"Input X contains NaN or infinity."}` = sklearn `_validate_data(force_all_finite=True)` (`_kernel_pca.py:438`/`:499`,`utils/validation.py:147-154`) — replaces the prior silent-garbage `Ok`. `tests/divergence_nonfinite.rs::divergence_kernel_pca_fit_nan_`/`_fit_inf_`/`_transform_nan_rejects_for_finiteness` match the live sklearn 1.5.2 oracle. Was #2288/#2289, fixed. Consumer: KernelPCA `fit`/`transform` + re-export `lib.rs` |
67//! | REQ-11 | f32/f64 generic | SHIPPED | `test_kernel_pca_f32` |
68//! | REQ-12 | `eigen_solver` arpack/randomized + tol/max_iter/iterated_power/random_state | NOT-STARTED | sklearn `_kernel_pca.py:340-366`; ferrolearn dense Jacobi only — blocker #1564 |
69//! | REQ-13 | `remove_zero_eig` | NOT-STARTED | sklearn `_kernel_pca.py:381-383` — blocker #1565 |
70//! | REQ-14 | `fit_inverse_transform`/`inverse_transform`/`alpha` ridge + `dual_coef_`/`X_transformed_fit_` | NOT-STARTED | sklearn `_kernel_pca.py:406-416,:514-563` — blocker #1566 |
71//! | REQ-15 | `n_components=None` + `kernel='precomputed'` + cosine/laplacian/chi2 kernels + kernel_params | NOT-STARTED | sklearn `_kernel_pca.py:319-326` — blocker #1567 |
72//! | REQ-16 | fitted attrs `n_features_in_`/`X_fit_` | NOT-STARTED | blocker #1568 |
73//! | REQ-17 | degenerate/repeated-eigenvalue subspace basis ambiguity | NOT-STARTED | CARVE-OUT (R-DEFER-3): Jacobi vs LAPACK eigh — blocker #1569 |
74//! | REQ-18 | ferray substrate | NOT-STARTED | `ndarray` + hand-rolled Jacobi — blocker #1570 |
75//!
76//! Count: **11 SHIPPED (REQ-1..11) / 7 NOT-STARTED (REQ-12..18)**.
77
78use ferrolearn_core::error::FerroError;
79use ferrolearn_core::pipeline::{FittedPipelineTransformer, PipelineTransformer};
80use ferrolearn_core::traits::{Fit, Transform};
81use ndarray::{Array1, Array2};
82use num_traits::Float;
83
84/// Reject non-finite input the way sklearn's `_validate_data` does.
85///
86/// sklearn runs `check_array` with the default `force_all_finite=True` at the
87/// top of `KernelPCA.fit`/`transform` (`_kernel_pca.py:438`), raising
88/// `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`
89/// (`sklearn/utils/validation.py:147-154`) BEFORE the kernel matrix /
90/// eigendecomposition. KernelPCA has no missing-value support, so NaN AND
91/// infinity are both rejected. The message names "NaN" and "infinity" to
92/// mirror sklearn. Never panics (R-CODE-2).
93fn reject_non_finite<F: Float>(x: &Array2<F>) -> Result<(), FerroError> {
94    if x.iter().any(|v| !v.is_finite()) {
95        return Err(FerroError::InvalidParameter {
96            name: "X".into(),
97            reason: "Input X contains NaN or infinity.".into(),
98        });
99    }
100    Ok(())
101}
102
103// ---------------------------------------------------------------------------
104// Kernel type
105// ---------------------------------------------------------------------------
106
107/// The kernel function for Kernel PCA.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Kernel {
110    /// Linear kernel: `K(x, y) = x . y`.
111    Linear,
112    /// RBF (Gaussian) kernel: `K(x, y) = exp(-gamma * ||x-y||^2)`.
113    RBF,
114    /// Polynomial kernel: `K(x, y) = (gamma * x . y + coef0)^degree`.
115    Polynomial,
116    /// Sigmoid kernel: `K(x, y) = tanh(gamma * x . y + coef0)`.
117    Sigmoid,
118}
119
120// ---------------------------------------------------------------------------
121// KernelPCA (unfitted)
122// ---------------------------------------------------------------------------
123
124/// Kernel PCA configuration.
125///
126/// Holds hyperparameters for the kernel PCA decomposition. Calling
127/// [`Fit::fit`] computes the kernel eigendecomposition and returns a
128/// [`FittedKernelPCA`] that can project new data via [`Transform::transform`].
129#[derive(Debug, Clone)]
130pub struct KernelPCA<F> {
131    /// Number of components to retain.
132    n_components: usize,
133    /// The kernel function.
134    kernel: Kernel,
135    /// Kernel coefficient. Defaults to `1.0 / n_features` for RBF.
136    gamma: Option<f64>,
137    /// Degree for polynomial kernel.
138    degree: usize,
139    /// Independent term for polynomial and sigmoid kernels.
140    coef0: f64,
141    _marker: std::marker::PhantomData<F>,
142}
143
144impl<F: Float + Send + Sync + 'static> KernelPCA<F> {
145    /// Create a new `KernelPCA` that retains `n_components` components.
146    ///
147    /// Defaults: kernel=`Linear`, gamma=`None` (auto: `1/n_features`),
148    /// degree=3, coef0=1.
149    #[must_use]
150    pub fn new(n_components: usize) -> Self {
151        Self {
152            n_components,
153            kernel: Kernel::Linear,
154            gamma: None,
155            degree: 3,
156            coef0: 1.0,
157            _marker: std::marker::PhantomData,
158        }
159    }
160
161    /// Set the kernel function.
162    #[must_use]
163    pub fn with_kernel(mut self, kernel: Kernel) -> Self {
164        self.kernel = kernel;
165        self
166    }
167
168    /// Set the gamma parameter for RBF, polynomial, and sigmoid kernels.
169    #[must_use]
170    pub fn with_gamma(mut self, gamma: f64) -> Self {
171        self.gamma = Some(gamma);
172        self
173    }
174
175    /// Set the degree for the polynomial kernel.
176    #[must_use]
177    pub fn with_degree(mut self, degree: usize) -> Self {
178        self.degree = degree;
179        self
180    }
181
182    /// Set the independent term for polynomial and sigmoid kernels.
183    #[must_use]
184    pub fn with_coef0(mut self, coef0: f64) -> Self {
185        self.coef0 = coef0;
186        self
187    }
188
189    /// Return the configured number of components.
190    #[must_use]
191    pub fn n_components(&self) -> usize {
192        self.n_components
193    }
194
195    /// Return the configured kernel.
196    #[must_use]
197    pub fn kernel(&self) -> Kernel {
198        self.kernel
199    }
200
201    /// Return the configured gamma, if any.
202    #[must_use]
203    pub fn gamma(&self) -> Option<f64> {
204        self.gamma
205    }
206
207    /// Return the configured polynomial degree.
208    #[must_use]
209    pub fn degree(&self) -> usize {
210        self.degree
211    }
212
213    /// Return the configured coef0.
214    #[must_use]
215    pub fn coef0(&self) -> f64 {
216        self.coef0
217    }
218}
219
220// ---------------------------------------------------------------------------
221// FittedKernelPCA
222// ---------------------------------------------------------------------------
223
224/// A fitted Kernel PCA model holding learned eigendecomposition.
225///
226/// Created by calling [`Fit::fit`] on a [`KernelPCA`]. Implements
227/// [`Transform<Array2<F>>`] to project new data.
228#[derive(Debug, Clone)]
229pub struct FittedKernelPCA<F> {
230    /// Scaled eigenvectors (alphas), shape `(n_samples, n_components)`.
231    /// Each column is an eigenvector scaled by `1 / sqrt(eigenvalue)`.
232    alphas_: Array2<F>,
233
234    /// Eigenvalues corresponding to each component (sorted descending).
235    eigenvalues_: Array1<F>,
236
237    /// Training data, kept for computing kernel with new data.
238    x_fit_: Array2<F>,
239
240    /// Column means of the training kernel matrix, shape `(n_samples,)`.
241    /// Used for centring the kernel of new data.
242    k_fit_col_means_: Array1<F>,
243
244    /// Grand mean of the training kernel matrix.
245    k_fit_grand_mean_: F,
246
247    /// Kernel configuration.
248    kernel: Kernel,
249    /// Effective gamma used.
250    gamma: f64,
251    /// Polynomial degree.
252    degree: usize,
253    /// Independent term.
254    coef0: f64,
255}
256
257impl<F: Float + Send + Sync + 'static> FittedKernelPCA<F> {
258    /// Scaled eigenvectors (alphas), shape `(n_samples, n_components)`.
259    #[must_use]
260    pub fn alphas(&self) -> &Array2<F> {
261        &self.alphas_
262    }
263
264    /// Eigenvalues corresponding to each component.
265    #[must_use]
266    pub fn eigenvalues(&self) -> &Array1<F> {
267        &self.eigenvalues_
268    }
269}
270
271// ---------------------------------------------------------------------------
272// Kernel computation helpers
273// ---------------------------------------------------------------------------
274
275/// Compute the kernel value between two vectors.
276fn kernel_value<F: Float>(
277    x: &[F],
278    y: &[F],
279    kernel: Kernel,
280    gamma: f64,
281    degree: usize,
282    coef0: f64,
283) -> F {
284    let gamma_f = F::from(gamma).unwrap();
285    let coef0_f = F::from(coef0).unwrap();
286
287    match kernel {
288        Kernel::Linear => {
289            let mut dot = F::zero();
290            for (&a, &b) in x.iter().zip(y.iter()) {
291                dot = dot + a * b;
292            }
293            dot
294        }
295        Kernel::RBF => {
296            let mut sq_dist = F::zero();
297            for (&a, &b) in x.iter().zip(y.iter()) {
298                let diff = a - b;
299                sq_dist = sq_dist + diff * diff;
300            }
301            (-gamma_f * sq_dist).exp()
302        }
303        Kernel::Polynomial => {
304            let mut dot = F::zero();
305            for (&a, &b) in x.iter().zip(y.iter()) {
306                dot = dot + a * b;
307            }
308            let base = gamma_f * dot + coef0_f;
309            let mut result = F::one();
310            for _ in 0..degree {
311                result = result * base;
312            }
313            result
314        }
315        Kernel::Sigmoid => {
316            let mut dot = F::zero();
317            for (&a, &b) in x.iter().zip(y.iter()) {
318                dot = dot + a * b;
319            }
320            (gamma_f * dot + coef0_f).tanh()
321        }
322    }
323}
324
325/// Compute the kernel matrix between rows of X1 and rows of X2.
326fn compute_kernel_matrix<F: Float>(
327    x1: &Array2<F>,
328    x2: &Array2<F>,
329    kernel: Kernel,
330    gamma: f64,
331    degree: usize,
332    coef0: f64,
333) -> Array2<F> {
334    let n1 = x1.nrows();
335    let n2 = x2.nrows();
336    let mut k = Array2::<F>::zeros((n1, n2));
337
338    for i in 0..n1 {
339        let row_i: Vec<F> = x1.row(i).to_vec();
340        for j in 0..n2 {
341            let row_j: Vec<F> = x2.row(j).to_vec();
342            k[[i, j]] = kernel_value(&row_i, &row_j, kernel, gamma, degree, coef0);
343        }
344    }
345
346    k
347}
348
349/// Centre a kernel matrix in feature space.
350///
351/// `K_c = K - 1_n K - K 1_n + 1_n K 1_n`
352/// where `1_n` is `(n, n)` with all entries `1/n`.
353fn centre_kernel_matrix<F: Float>(k: &mut Array2<F>) {
354    let n = k.nrows();
355    let n_f = F::from(n).unwrap();
356
357    // Compute column means.
358    let mut col_means = Array1::<F>::zeros(n);
359    for j in 0..n {
360        let mut sum = F::zero();
361        for i in 0..n {
362            sum = sum + k[[i, j]];
363        }
364        col_means[j] = sum / n_f;
365    }
366
367    // Compute grand mean.
368    let grand_mean = col_means.iter().copied().fold(F::zero(), |a, b| a + b) / n_f;
369
370    // Centre: K[i,j] = K[i,j] - col_mean[j] - row_mean[i] + grand_mean
371    // For a symmetric matrix, col_mean == row_mean.
372    for i in 0..n {
373        for j in 0..n {
374            k[[i, j]] = k[[i, j]] - col_means[i] - col_means[j] + grand_mean;
375        }
376    }
377}
378
379/// Jacobi eigendecomposition for symmetric matrices (local copy).
380fn jacobi_eigen_symmetric<F: Float + Send + Sync + 'static>(
381    a: &Array2<F>,
382    max_iter: usize,
383) -> Result<(Array1<F>, Array2<F>), FerroError> {
384    let n = a.nrows();
385    if n == 0 {
386        return Ok((Array1::zeros(0), Array2::zeros((0, 0))));
387    }
388    if n == 1 {
389        let eigenvalues = Array1::from_vec(vec![a[[0, 0]]]);
390        let eigenvectors = Array2::from_shape_vec((1, 1), vec![F::one()]).unwrap();
391        return Ok((eigenvalues, eigenvectors));
392    }
393
394    let mut mat = a.to_owned();
395    let mut v = Array2::<F>::zeros((n, n));
396    for i in 0..n {
397        v[[i, i]] = F::one();
398    }
399
400    let tol = F::from(1e-12).unwrap_or_else(F::epsilon);
401
402    for _iteration in 0..max_iter {
403        let mut max_off = F::zero();
404        let mut p = 0;
405        let mut q = 1;
406        for i in 0..n {
407            for j in (i + 1)..n {
408                let val = mat[[i, j]].abs();
409                if val > max_off {
410                    max_off = val;
411                    p = i;
412                    q = j;
413                }
414            }
415        }
416
417        if max_off < tol {
418            let eigenvalues = Array1::from_shape_fn(n, |i| mat[[i, i]]);
419            return Ok((eigenvalues, v));
420        }
421
422        let app = mat[[p, p]];
423        let aqq = mat[[q, q]];
424        let apq = mat[[p, q]];
425
426        let theta = if (app - aqq).abs() < tol {
427            F::from(std::f64::consts::FRAC_PI_4).unwrap_or_else(F::one)
428        } else {
429            let tau = (aqq - app) / (F::from(2.0).unwrap() * apq);
430            let t = if tau >= F::zero() {
431                F::one() / (tau.abs() + (F::one() + tau * tau).sqrt())
432            } else {
433                -F::one() / (tau.abs() + (F::one() + tau * tau).sqrt())
434            };
435            t.atan()
436        };
437
438        let c = theta.cos();
439        let s = theta.sin();
440
441        let mut new_mat = mat.clone();
442        for i in 0..n {
443            if i != p && i != q {
444                let mip = mat[[i, p]];
445                let miq = mat[[i, q]];
446                new_mat[[i, p]] = c * mip - s * miq;
447                new_mat[[p, i]] = new_mat[[i, p]];
448                new_mat[[i, q]] = s * mip + c * miq;
449                new_mat[[q, i]] = new_mat[[i, q]];
450            }
451        }
452
453        new_mat[[p, p]] = c * c * app - F::from(2.0).unwrap() * s * c * apq + s * s * aqq;
454        new_mat[[q, q]] = s * s * app + F::from(2.0).unwrap() * s * c * apq + c * c * aqq;
455        new_mat[[p, q]] = F::zero();
456        new_mat[[q, p]] = F::zero();
457
458        mat = new_mat;
459
460        for i in 0..n {
461            let vip = v[[i, p]];
462            let viq = v[[i, q]];
463            v[[i, p]] = c * vip - s * viq;
464            v[[i, q]] = s * vip + c * viq;
465        }
466    }
467
468    Err(FerroError::ConvergenceFailure {
469        iterations: max_iter,
470        message: "Jacobi eigendecomposition did not converge in KernelPCA".into(),
471    })
472}
473
474// ---------------------------------------------------------------------------
475// Trait implementations
476// ---------------------------------------------------------------------------
477
478impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, ()> for KernelPCA<F> {
479    type Fitted = FittedKernelPCA<F>;
480    type Error = FerroError;
481
482    /// Fit Kernel PCA by computing the kernel matrix, centring it in
483    /// feature space, and eigendecomposing.
484    ///
485    /// `n_components` greater than the number of samples is CLAMPED to
486    /// `min(n_samples, n_components)` and fitted (matching sklearn
487    /// `_kernel_pca.py:337`), not rejected.
488    ///
489    /// # Errors
490    ///
491    /// - [`FerroError::InvalidParameter`] if `n_components` is zero, or if the
492    ///   centered kernel is not positive semi-definite (a significant negative
493    ///   eigenvalue, matching sklearn `_check_psd_eigenvalues`).
494    /// - [`FerroError::InsufficientSamples`] if there are fewer than 2 samples.
495    /// - [`FerroError::ConvergenceFailure`] if the Jacobi eigendecomposition
496    ///   does not converge.
497    fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedKernelPCA<F>, FerroError> {
498        let (n_samples, n_features) = x.dim();
499
500        if self.n_components == 0 {
501            return Err(FerroError::InvalidParameter {
502                name: "n_components".into(),
503                reason: "must be at least 1".into(),
504            });
505        }
506        if n_samples < 2 {
507            return Err(FerroError::InsufficientSamples {
508                required: 2,
509                actual: n_samples,
510                context: "KernelPCA::fit requires at least 2 samples".into(),
511            });
512        }
513        // sklearn caps the effective number of components at the kernel matrix
514        // dimension rather than erroring: `n_components = min(K.shape[0],
515        // self.n_components)` (`_kernel_pca.py:337`). The centered kernel is
516        // `n_samples x n_samples`, so at most `n_samples` components exist.
517        // ferrolearn previously returned `Err(InvalidParameter)` here (#2389);
518        // now it clamps and fits to match sklearn.
519        let n_comp_effective = self.n_components.min(n_samples);
520
521        // Finiteness: sklearn `KernelPCA.fit` runs `_validate_data`
522        // (`_kernel_pca.py:438`) with the default `force_all_finite=True`,
523        // raising `ValueError("Input X contains NaN."/"...infinity...")`
524        // (`utils/validation.py:147-154`) BEFORE the kernel matrix /
525        // eigendecomposition. NaN AND infinity both rejected — replaces the
526        // prior silent-garbage `Ok` (#2288).
527        reject_non_finite(x)?;
528
529        // Determine effective gamma.
530        let effective_gamma = self.gamma.unwrap_or(1.0 / n_features as f64);
531
532        // Step 1: Compute kernel matrix.
533        let mut k =
534            compute_kernel_matrix(x, x, self.kernel, effective_gamma, self.degree, self.coef0);
535
536        // Save column means and grand mean before centring (needed for transform).
537        let n_f = F::from(n_samples).unwrap();
538        let mut k_col_means = Array1::<F>::zeros(n_samples);
539        for j in 0..n_samples {
540            let mut sum = F::zero();
541            for i in 0..n_samples {
542                sum = sum + k[[i, j]];
543            }
544            k_col_means[j] = sum / n_f;
545        }
546        let k_grand_mean = k_col_means.iter().copied().fold(F::zero(), |a, b| a + b) / n_f;
547
548        // Step 2: Centre kernel matrix.
549        centre_kernel_matrix(&mut k);
550
551        // Step 3: Eigendecompose.
552        let max_iter = n_samples * n_samples * 100 + 1000;
553        let (eigenvalues, eigenvectors) = jacobi_eigen_symmetric(&k, max_iter)?;
554
555        // Step 4: Sort descending, pick top n_components.
556        let mut indices: Vec<usize> = (0..n_samples).collect();
557        indices.sort_by(|&a, &b| {
558            eigenvalues[b]
559                .partial_cmp(&eigenvalues[a])
560                .unwrap_or(std::cmp::Ordering::Equal)
561        });
562
563        let n_comp = n_comp_effective;
564
565        // PSD check (sklearn `_check_psd_eigenvalues`, `_kernel_pca.py:368`,
566        // `utils/validation.py:1942-1963`): sklearn runs the check on the
567        // eigenvalues RETURNED BY `eigh(K, subset_by_index=(N-n_comp, N-1))`
568        // (`_kernel_pca.py:350-352`) — i.e. on the SELECTED top-`n_comp`
569        // eigenvalues, NOT the full spectrum. A SIGNIFICANT negative eigenvalue
570        // among that subset (`min_eig < -significant_neg_ratio * max_eig` AND
571        // `min_eig < -significant_neg_value`, with `significant_neg_ratio=1e-5`,
572        // `significant_neg_value=1e-10` for double precision) means the centered
573        // kernel is not PSD → sklearn RAISES `ValueError`. ferrolearn previously
574        // silently clamped ALL negatives to 0 and returned a garbage embedding
575        // (#2390); now it surfaces an error. Tiny (numerical-noise) negatives in
576        // the selected subset are still clipped to 0 below. The thresholds use
577        // the f64 constants (sklearn `is_double_precision` branch); f32 inputs
578        // are evaluated with the same ratio/value cast into `F` — the ratio test
579        // dominates and is dtype-agnostic for the non-PSD case being guarded.
580        {
581            let selected: Vec<F> = indices
582                .iter()
583                .take(n_comp)
584                .map(|&i| eigenvalues[i])
585                .collect();
586            if !selected.is_empty() {
587                let max_eig = selected
588                    .iter()
589                    .copied()
590                    .fold(F::neg_infinity(), |a, b| if b > a { b } else { a });
591                let min_eig = selected
592                    .iter()
593                    .copied()
594                    .fold(F::infinity(), |a, b| if b < a { b } else { a });
595                let significant_neg_ratio = F::from(1e-5).unwrap_or_else(F::epsilon);
596                let significant_neg_value = F::from(1e-10).unwrap_or_else(F::epsilon);
597                if max_eig > F::zero()
598                    && min_eig < -significant_neg_ratio * max_eig
599                    && min_eig < -significant_neg_value
600                {
601                    return Err(FerroError::InvalidParameter {
602                        name: "kernel".into(),
603                        reason: "There are significant negative eigenvalues. \
604                                 Either the matrix is not positive semi-definite (PSD), \
605                                 or there was an issue while computing the \
606                                 eigendecomposition of the matrix."
607                            .into(),
608                    });
609                }
610            }
611        }
612
613        let mut alphas = Array2::<F>::zeros((n_samples, n_comp));
614        let mut top_eigenvalues = Array1::<F>::zeros(n_comp);
615
616        for (k_idx, &eigen_idx) in indices.iter().take(n_comp).enumerate() {
617            let eigval = eigenvalues[eigen_idx];
618            let eigval_clamped = if eigval > F::zero() {
619                eigval
620            } else {
621                F::zero()
622            };
623            top_eigenvalues[k_idx] = eigval_clamped;
624
625            // Scale eigenvector by 1/sqrt(eigenvalue).
626            let scale = if eigval_clamped > F::from(1e-12).unwrap_or_else(F::epsilon) {
627                F::one() / eigval_clamped.sqrt()
628            } else {
629                F::zero()
630            };
631
632            for i in 0..n_samples {
633                alphas[[i, k_idx]] = eigenvectors[[i, eigen_idx]] * scale;
634            }
635        }
636
637        // svd_flip(u=eigenvectors_, v=None): u_based_decision=True
638        // (`_kernel_pca.py:373`, `extmath.py:888-894`). For each eigenvector
639        // COLUMN, numpy `argmax(abs(u), axis=0)` selects the FIRST max-abs ROW
640        // (strict `>` => first-max-wins) and `signs = sign(u[max_abs_row, col])`
641        // is applied so that column's max-abs entry becomes POSITIVE. The
642        // positive `1/sqrt(eigenvalue)` scale preserves sign, so flipping the
643        // scaled `alphas` column matches flipping the raw eigenvector column.
644        for k_idx in 0..n_comp {
645            let mut i_max = 0usize;
646            let mut max_abs = F::zero();
647            for i in 0..n_samples {
648                let a = alphas[[i, k_idx]].abs();
649                if a > max_abs {
650                    max_abs = a;
651                    i_max = i;
652                }
653            }
654            if alphas[[i_max, k_idx]] < F::zero() {
655                for i in 0..n_samples {
656                    alphas[[i, k_idx]] = -alphas[[i, k_idx]];
657                }
658            }
659        }
660
661        Ok(FittedKernelPCA {
662            alphas_: alphas,
663            eigenvalues_: top_eigenvalues,
664            x_fit_: x.to_owned(),
665            k_fit_col_means_: k_col_means,
666            k_fit_grand_mean_: k_grand_mean,
667            kernel: self.kernel,
668            gamma: effective_gamma,
669            degree: self.degree,
670            coef0: self.coef0,
671        })
672    }
673}
674
675impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedKernelPCA<F> {
676    type Output = Array2<F>;
677    type Error = FerroError;
678
679    /// Project new data onto the learned kernel principal components.
680    ///
681    /// Computes the kernel between the new data and the training data,
682    /// centres it appropriately, then projects using the learned eigenvectors.
683    ///
684    /// # Errors
685    ///
686    /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
687    /// match the number seen during fitting.
688    fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
689        let n_features = self.x_fit_.ncols();
690        if x.ncols() != n_features {
691            return Err(FerroError::ShapeMismatch {
692                expected: vec![x.nrows(), n_features],
693                actual: vec![x.nrows(), x.ncols()],
694                context: "FittedKernelPCA::transform".into(),
695            });
696        }
697
698        // Finiteness on the query X: sklearn `KernelPCA.transform` runs
699        // `_validate_data(..., reset=False)` (`_kernel_pca.py:499`),
700        // `force_all_finite=True` raising a `ValueError` BEFORE the kernel
701        // projection (`utils/validation.py:147-154`). NaN AND infinity both
702        // rejected (#2289).
703        reject_non_finite(x)?;
704
705        let n_test = x.nrows();
706        let n_train = self.x_fit_.nrows();
707        let n_f = F::from(n_train).unwrap();
708
709        // Compute kernel matrix between test and training data.
710        let k_test = compute_kernel_matrix(
711            x,
712            &self.x_fit_,
713            self.kernel,
714            self.gamma,
715            self.degree,
716            self.coef0,
717        );
718
719        // Centre the test kernel matrix.
720        // K_test_centered[i,j] = K_test[i,j] - mean_train_col[j]
721        //                        - mean_test_row[i] + grand_mean_train
722        // where mean_test_row[i] = (1/n_train) * sum_j K_test[i,j]
723
724        let mut k_centered = Array2::<F>::zeros((n_test, n_train));
725        for i in 0..n_test {
726            // Row mean of the test kernel row.
727            let mut row_mean = F::zero();
728            for j in 0..n_train {
729                row_mean = row_mean + k_test[[i, j]];
730            }
731            row_mean = row_mean / n_f;
732
733            for j in 0..n_train {
734                k_centered[[i, j]] =
735                    k_test[[i, j]] - self.k_fit_col_means_[j] - row_mean + self.k_fit_grand_mean_;
736            }
737        }
738
739        // Project: X_new = K_centered @ alphas
740        Ok(k_centered.dot(&self.alphas_))
741    }
742}
743
744// ---------------------------------------------------------------------------
745// Pipeline integration (generic)
746// ---------------------------------------------------------------------------
747
748impl<F: Float + Send + Sync + 'static> PipelineTransformer<F> for KernelPCA<F> {
749    /// Fit KernelPCA using the pipeline interface.
750    ///
751    /// The `y` argument is ignored; Kernel PCA is unsupervised.
752    ///
753    /// # Errors
754    ///
755    /// Propagates errors from [`Fit::fit`].
756    fn fit_pipeline(
757        &self,
758        x: &Array2<F>,
759        _y: &Array1<F>,
760    ) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError> {
761        let fitted = self.fit(x, &())?;
762        Ok(Box::new(fitted))
763    }
764}
765
766impl<F: Float + Send + Sync + 'static> FittedPipelineTransformer<F> for FittedKernelPCA<F> {
767    /// Transform data using the pipeline interface.
768    ///
769    /// # Errors
770    ///
771    /// Propagates errors from [`Transform::transform`].
772    fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
773        self.transform(x)
774    }
775}
776
777// ---------------------------------------------------------------------------
778// Tests
779// ---------------------------------------------------------------------------
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use approx::assert_abs_diff_eq;
785    use ndarray::array;
786
787    /// Helper: create a dataset with some non-linear structure.
788    fn circle_dataset() -> Array2<f64> {
789        // Points roughly on two concentric circles.
790        array![
791            [1.0, 0.0],
792            [0.0, 1.0],
793            [-1.0, 0.0],
794            [0.0, -1.0],
795            [2.0, 0.0],
796            [0.0, 2.0],
797            [-2.0, 0.0],
798            [0.0, -2.0],
799        ]
800    }
801
802    /// Helper: create a simple linear dataset.
803    fn linear_dataset() -> Array2<f64> {
804        array![
805            [1.0, 2.0, 3.0],
806            [4.0, 5.0, 6.0],
807            [7.0, 8.0, 9.0],
808            [10.0, 11.0, 12.0],
809            [13.0, 14.0, 15.0],
810        ]
811    }
812
813    #[test]
814    fn test_kernel_pca_linear_basic() {
815        let kpca = KernelPCA::<f64>::new(2).with_kernel(Kernel::Linear);
816        let x = linear_dataset();
817        let fitted = kpca.fit(&x, &()).unwrap();
818        let projected = fitted.transform(&x).unwrap();
819        assert_eq!(projected.dim(), (5, 2));
820    }
821
822    #[test]
823    fn test_kernel_pca_rbf_basic() {
824        let kpca = KernelPCA::<f64>::new(2)
825            .with_kernel(Kernel::RBF)
826            .with_gamma(0.5);
827        let x = circle_dataset();
828        let fitted = kpca.fit(&x, &()).unwrap();
829        let projected = fitted.transform(&x).unwrap();
830        assert_eq!(projected.dim(), (8, 2));
831    }
832
833    #[test]
834    fn test_kernel_pca_polynomial_basic() {
835        let kpca = KernelPCA::<f64>::new(2)
836            .with_kernel(Kernel::Polynomial)
837            .with_degree(2)
838            .with_gamma(1.0)
839            .with_coef0(1.0);
840        let x = circle_dataset();
841        let fitted = kpca.fit(&x, &()).unwrap();
842        let projected = fitted.transform(&x).unwrap();
843        assert_eq!(projected.dim(), (8, 2));
844    }
845
846    #[test]
847    fn test_kernel_pca_sigmoid_basic() {
848        let kpca = KernelPCA::<f64>::new(2)
849            .with_kernel(Kernel::Sigmoid)
850            .with_gamma(0.01)
851            .with_coef0(0.0);
852        let x = linear_dataset();
853        let fitted = kpca.fit(&x, &()).unwrap();
854        let projected = fitted.transform(&x).unwrap();
855        assert_eq!(projected.dim(), (5, 2));
856    }
857
858    #[test]
859    fn test_kernel_pca_eigenvalues_non_negative() {
860        let kpca = KernelPCA::<f64>::new(3)
861            .with_kernel(Kernel::RBF)
862            .with_gamma(0.1);
863        let x = circle_dataset();
864        let fitted = kpca.fit(&x, &()).unwrap();
865        for &ev in fitted.eigenvalues() {
866            assert!(ev >= 0.0, "eigenvalue should be non-negative, got {ev}");
867        }
868    }
869
870    #[test]
871    fn test_kernel_pca_eigenvalues_sorted_descending() {
872        let kpca = KernelPCA::<f64>::new(3)
873            .with_kernel(Kernel::RBF)
874            .with_gamma(0.1);
875        let x = circle_dataset();
876        let fitted = kpca.fit(&x, &()).unwrap();
877        let ev = fitted.eigenvalues();
878        for i in 1..ev.len() {
879            assert!(
880                ev[i - 1] >= ev[i] - 1e-10,
881                "eigenvalues not sorted: ev[{}]={} < ev[{}]={}",
882                i - 1,
883                ev[i - 1],
884                i,
885                ev[i]
886            );
887        }
888    }
889
890    #[test]
891    fn test_kernel_pca_single_component() {
892        let kpca = KernelPCA::<f64>::new(1)
893            .with_kernel(Kernel::RBF)
894            .with_gamma(0.5);
895        let x = circle_dataset();
896        let fitted = kpca.fit(&x, &()).unwrap();
897        assert_eq!(fitted.alphas().ncols(), 1);
898        assert_eq!(fitted.eigenvalues().len(), 1);
899        let projected = fitted.transform(&x).unwrap();
900        assert_eq!(projected.ncols(), 1);
901    }
902
903    #[test]
904    fn test_kernel_pca_invalid_n_components_zero() {
905        let kpca = KernelPCA::<f64>::new(0);
906        let x = linear_dataset();
907        assert!(kpca.fit(&x, &()).is_err());
908    }
909
910    #[test]
911    fn test_kernel_pca_n_components_too_large_clamps() {
912        // sklearn clamps n_components to min(n_samples, n_components) and fits
913        // (`_kernel_pca.py:337`), it does NOT error (#2389).
914        let kpca = KernelPCA::<f64>::new(20).with_kernel(Kernel::Linear);
915        let x = linear_dataset(); // 5 samples
916        let eig_len = kpca.fit(&x, &()).ok().map(|f| f.eigenvalues().len());
917        assert_eq!(
918            eig_len,
919            Some(5),
920            "clamps to n_samples=5 eigenvalues and fits (_kernel_pca.py:337)"
921        );
922        let shape = kpca
923            .fit(&x, &())
924            .and_then(|f| f.transform(&x))
925            .ok()
926            .map(|p| p.dim());
927        assert_eq!(shape, Some((5, 5)));
928    }
929
930    #[test]
931    fn test_kernel_pca_insufficient_samples() {
932        let kpca = KernelPCA::<f64>::new(1);
933        let x = array![[1.0, 2.0]]; // only 1 sample
934        assert!(kpca.fit(&x, &()).is_err());
935    }
936
937    #[test]
938    fn test_kernel_pca_shape_mismatch_transform() {
939        let kpca = KernelPCA::<f64>::new(1).with_kernel(Kernel::Linear);
940        let x = linear_dataset();
941        let fitted = kpca.fit(&x, &()).unwrap();
942        let x_bad = array![[1.0, 2.0]]; // 2 features instead of 3
943        assert!(fitted.transform(&x_bad).is_err());
944    }
945
946    #[test]
947    fn test_kernel_pca_transform_new_data() {
948        let kpca = KernelPCA::<f64>::new(2)
949            .with_kernel(Kernel::RBF)
950            .with_gamma(0.5);
951        let x_train = circle_dataset();
952        let fitted = kpca.fit(&x_train, &()).unwrap();
953        let x_test = array![[1.5, 0.5], [-0.5, 1.5]];
954        let projected = fitted.transform(&x_test).unwrap();
955        assert_eq!(projected.dim(), (2, 2));
956    }
957
958    #[test]
959    fn test_kernel_pca_auto_gamma() {
960        // When gamma is not set, it should default to 1/n_features.
961        let kpca = KernelPCA::<f64>::new(2).with_kernel(Kernel::RBF);
962        let x = linear_dataset(); // 3 features, so gamma = 1/3
963        let fitted = kpca.fit(&x, &()).unwrap();
964        // Just verify it ran without error.
965        let projected = fitted.transform(&x).unwrap();
966        assert_eq!(projected.dim(), (5, 2));
967    }
968
969    #[test]
970    fn test_kernel_pca_getters() {
971        let kpca = KernelPCA::<f64>::new(3)
972            .with_kernel(Kernel::Polynomial)
973            .with_gamma(0.5)
974            .with_degree(4)
975            .with_coef0(2.0);
976        assert_eq!(kpca.n_components(), 3);
977        assert_eq!(kpca.kernel(), Kernel::Polynomial);
978        assert_eq!(kpca.gamma(), Some(0.5));
979        assert_eq!(kpca.degree(), 4);
980        assert_abs_diff_eq!(kpca.coef0(), 2.0);
981    }
982
983    #[test]
984    fn test_kernel_pca_f32() {
985        let kpca = KernelPCA::<f32>::new(1).with_kernel(Kernel::Linear);
986        let x: Array2<f32> = array![[1.0f32, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0],];
987        let fitted = kpca.fit(&x, &()).unwrap();
988        let projected = fitted.transform(&x).unwrap();
989        assert_eq!(projected.ncols(), 1);
990    }
991
992    #[test]
993    fn test_kernel_pca_linear_resembles_pca() {
994        // Linear kernel PCA should produce results similar to standard PCA
995        // (up to sign and scale).
996        let kpca = KernelPCA::<f64>::new(1).with_kernel(Kernel::Linear);
997        let x = linear_dataset();
998        let fitted = kpca.fit(&x, &()).unwrap();
999        let projected = fitted.transform(&x).unwrap();
1000        // The projection should be 1-dimensional and the values should
1001        // be linearly spaced (since the data lies on a line).
1002        assert_eq!(projected.ncols(), 1);
1003        // Check that differences between consecutive projections are roughly equal.
1004        let diffs: Vec<f64> = (1..projected.nrows())
1005            .map(|i| (projected[[i, 0]] - projected[[i - 1, 0]]).abs())
1006            .collect();
1007        for d in &diffs {
1008            assert_abs_diff_eq!(d, &diffs[0], epsilon = 1e-6);
1009        }
1010    }
1011
1012    #[test]
1013    fn test_kernel_pca_pipeline_integration() {
1014        use ferrolearn_core::pipeline::{FittedPipelineEstimator, Pipeline, PipelineEstimator};
1015        use ferrolearn_core::traits::Predict;
1016
1017        struct SumEstimator;
1018
1019        impl PipelineEstimator<f64> for SumEstimator {
1020            fn fit_pipeline(
1021                &self,
1022                _x: &Array2<f64>,
1023                _y: &Array1<f64>,
1024            ) -> Result<Box<dyn FittedPipelineEstimator<f64>>, FerroError> {
1025                Ok(Box::new(FittedSumEstimator))
1026            }
1027        }
1028
1029        struct FittedSumEstimator;
1030
1031        impl FittedPipelineEstimator<f64> for FittedSumEstimator {
1032            fn predict_pipeline(&self, x: &Array2<f64>) -> Result<Array1<f64>, FerroError> {
1033                let sums: Vec<f64> = x.rows().into_iter().map(|r| r.sum()).collect();
1034                Ok(Array1::from_vec(sums))
1035            }
1036        }
1037
1038        let pipeline = Pipeline::new()
1039            .transform_step(
1040                "kpca",
1041                Box::new(
1042                    KernelPCA::<f64>::new(2)
1043                        .with_kernel(Kernel::RBF)
1044                        .with_gamma(0.5),
1045                ),
1046            )
1047            .estimator_step("sum", Box::new(SumEstimator));
1048
1049        let x = circle_dataset();
1050        let y = Array1::from_vec(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
1051
1052        let fitted = pipeline.fit(&x, &y).unwrap();
1053        let preds = fitted.predict(&x).unwrap();
1054        assert_eq!(preds.len(), 8);
1055    }
1056
1057    #[test]
1058    fn test_kernel_pca_max_components_equals_samples() {
1059        let kpca = KernelPCA::<f64>::new(5).with_kernel(Kernel::Linear);
1060        let x = linear_dataset(); // 5 samples
1061        let fitted = kpca.fit(&x, &()).unwrap();
1062        assert_eq!(fitted.eigenvalues().len(), 5);
1063    }
1064
1065    #[test]
1066    fn test_kernel_pca_rbf_sensitivity_to_gamma() {
1067        // Different gamma values should produce different projections.
1068        let kpca_small = KernelPCA::<f64>::new(2)
1069            .with_kernel(Kernel::RBF)
1070            .with_gamma(0.01);
1071        let kpca_large = KernelPCA::<f64>::new(2)
1072            .with_kernel(Kernel::RBF)
1073            .with_gamma(10.0);
1074        let x = circle_dataset();
1075        let fitted_small = kpca_small.fit(&x, &()).unwrap();
1076        let fitted_large = kpca_large.fit(&x, &()).unwrap();
1077        let proj_small = fitted_small.transform(&x).unwrap();
1078        let proj_large = fitted_large.transform(&x).unwrap();
1079        // The projections should differ.
1080        let mut diff_sum = 0.0;
1081        for (a, b) in proj_small.iter().zip(proj_large.iter()) {
1082            diff_sum += (a - b).abs();
1083        }
1084        assert!(
1085            diff_sum > 1e-6,
1086            "different gamma should produce different projections"
1087        );
1088    }
1089}