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}