Skip to main content

tensorlogic_sklears_kernels/kernel_pca/
model.rs

1//! The [`KernelPCA`] estimator and its fitted counterpart
2//! [`FittedKernelPCA`].
3//!
4//! This module wires together the centering utilities in
5//! [`crate::kernel_pca::centering`] and the eigendecomposition in
6//! [`crate::kernel_pca::eigendecomp`] into the standard scikit-learn
7//! style estimator API:
8//!
9//! ```text
10//! let model = KernelPCA::new(kernel, KernelPcaConfig::new(2));
11//! let fitted = model.fit(&training_data)?;
12//! let embedding = fitted.transform(&new_points)?;
13//! ```
14//!
15//! `fit_transform` is also provided as a convenience when the training
16//! data itself is to be embedded (this is how a pipeline like
17//! *Gaussian KPCA for visualisation* is usually driven).
18
19use scirs2_core::ndarray::{Array1, Array2};
20
21use crate::error::KernelError;
22use crate::kernel_pca::centering::{center_test_kernel, double_center, KernelCenteringStats};
23use crate::kernel_pca::eigendecomp::{symmetric_eigendecomp, TopKEigen};
24use crate::kernel_pca::error::{KernelPcaError, KernelPcaResult};
25use crate::types::Kernel;
26
27/// Configuration for [`KernelPCA`].
28///
29/// Cheap to clone and `Debug`/`PartialEq`-comparable so it composes
30/// cleanly inside pipelines and hyperparameter sweeps.
31#[derive(Clone, Debug, PartialEq)]
32pub struct KernelPcaConfig {
33    /// Number of principal components to retain.
34    pub n_components: usize,
35    /// Whether to double-center the Gram matrix before eigendecomp.
36    ///
37    /// Leaving this `true` (the default) is the standard Kernel PCA
38    /// behaviour; setting it to `false` lets callers who have already
39    /// centered their kernel (e.g. when chaining two `kernel_pca`
40    /// instances) skip the redundant step.
41    pub center: bool,
42}
43
44impl KernelPcaConfig {
45    /// Build a configuration requesting `n_components` components with
46    /// centering enabled.
47    pub fn new(n_components: usize) -> Self {
48        Self {
49            n_components,
50            center: true,
51        }
52    }
53
54    /// Override the centering flag.
55    pub fn with_center(mut self, center: bool) -> Self {
56        self.center = center;
57        self
58    }
59}
60
61/// Kernel-PCA estimator generic over any kernel that implements the
62/// crate's [`Kernel`] trait. Typical ones are
63/// [`crate::RbfKernel`], [`crate::LinearKernel`], and
64/// [`crate::PolynomialKernel`], but [`crate::SymbolicKernel`] (built
65/// with [`crate::KernelBuilder`]) also slots in.
66///
67/// `KernelPCA` is stateless — the "fitted model" is
68/// [`FittedKernelPCA`], returned by [`KernelPCA::fit`] or
69/// [`KernelPCA::fit_transform`].
70#[derive(Clone, Debug)]
71pub struct KernelPCA<K: Kernel> {
72    kernel: K,
73    config: KernelPcaConfig,
74}
75
76impl<K: Kernel> KernelPCA<K> {
77    /// Build a new Kernel-PCA estimator from a kernel and a config.
78    ///
79    /// # Errors
80    ///
81    /// * [`KernelPcaError::InvalidInput`] when the config requests
82    ///   `n_components == 0`.
83    pub fn new(kernel: K, config: KernelPcaConfig) -> KernelPcaResult<Self> {
84        if config.n_components == 0 {
85            return Err(KernelPcaError::InvalidInput(
86                "KernelPCA::new: n_components must be >= 1".to_string(),
87            ));
88        }
89        Ok(Self { kernel, config })
90    }
91
92    /// Access the underlying kernel (useful for diagnostics).
93    pub fn kernel(&self) -> &K {
94        &self.kernel
95    }
96
97    /// Access the configuration.
98    pub fn config(&self) -> &KernelPcaConfig {
99        &self.config
100    }
101}
102
103/// A fitted Kernel-PCA model. Stores everything required to project
104/// new data into the learned principal subspace: the kernel, the
105/// scaled eigenvectors `alpha = v / sqrt(lambda)`, the raw
106/// eigenvalues, the training points, and the centering statistics.
107pub struct FittedKernelPCA<K: Kernel> {
108    kernel: Box<dyn Kernel>,
109    alphas: Array2<f64>,
110    eigenvalues: Array1<f64>,
111    training_data: Vec<Vec<f64>>,
112    centering_stats: KernelCenteringStats,
113    n_components: usize,
114    feature_dim: usize,
115    // Phantom to remember the original static kernel type for the
116    // convenience accessor below.
117    _marker: std::marker::PhantomData<K>,
118}
119
120impl<K: Kernel> std::fmt::Debug for FittedKernelPCA<K> {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("FittedKernelPCA")
123            .field("kernel_name", &self.kernel.name())
124            .field("n_components", &self.n_components)
125            .field("feature_dim", &self.feature_dim)
126            .field("n_training_points", &self.training_data.len())
127            .field("eigenvalues", &self.eigenvalues)
128            .finish()
129    }
130}
131
132impl<K: Kernel> FittedKernelPCA<K> {
133    /// Number of components kept at `fit` time.
134    pub fn n_components(&self) -> usize {
135        self.n_components
136    }
137
138    /// Feature dimension expected by [`Self::transform`].
139    pub fn feature_dim(&self) -> usize {
140        self.feature_dim
141    }
142
143    /// Eigenvalues of the centered Gram matrix corresponding to the
144    /// retained components, sorted in descending order.
145    pub fn eigenvalues(&self) -> &Array1<f64> {
146        &self.eigenvalues
147    }
148
149    /// Scaled eigenvectors `alpha_k = v_k / sqrt(lambda_k)` as an
150    /// `(n_training, n_components)` matrix.
151    pub fn alphas(&self) -> &Array2<f64> {
152        &self.alphas
153    }
154
155    /// Borrowed view of the training data retained for projection.
156    pub fn training_data(&self) -> &[Vec<f64>] {
157        &self.training_data
158    }
159
160    /// Centering statistics captured at `fit` time.
161    pub fn centering_stats(&self) -> &KernelCenteringStats {
162        &self.centering_stats
163    }
164
165    /// Fraction of centered-Gram variance explained by each retained
166    /// component. The `i`-th entry is
167    /// `eigenvalues[i] / sum_j eigenvalues[j]`. Returns a zero-length
168    /// array if the kept eigenvalues are (numerically) zero.
169    pub fn explained_variance_ratio(&self) -> Array1<f64> {
170        let total: f64 = self.eigenvalues.iter().copied().sum();
171        if total <= 0.0 {
172            return Array1::<f64>::zeros(self.n_components);
173        }
174        let mut out = Array1::<f64>::zeros(self.n_components);
175        for (i, v) in self.eigenvalues.iter().enumerate() {
176            out[i] = v / total;
177        }
178        out
179    }
180
181    /// Project new points into the learned principal subspace.
182    ///
183    /// The returned matrix has shape `(points.len(), n_components)`
184    /// and row `i` is the embedding of `points[i]`.
185    ///
186    /// # Errors
187    ///
188    /// * [`KernelPcaError::DimensionMismatch`] if any row of `points`
189    ///   has a different feature dimension than the training data.
190    /// * [`KernelPcaError::InvalidInput`] if `points` is empty.
191    pub fn transform(&self, points: &[Vec<f64>]) -> KernelPcaResult<Array2<f64>> {
192        if points.is_empty() {
193            return Err(KernelPcaError::InvalidInput(
194                "FittedKernelPCA::transform: points must not be empty".to_string(),
195            ));
196        }
197        let n_train = self.training_data.len();
198        let k = self.n_components;
199        let mut out = Array2::<f64>::zeros((points.len(), k));
200
201        for (pi, point) in points.iter().enumerate() {
202            if point.len() != self.feature_dim {
203                return Err(KernelPcaError::DimensionMismatch {
204                    expected: self.feature_dim,
205                    got: point.len(),
206                    context: format!("FittedKernelPCA::transform: points[{}]", pi),
207                });
208            }
209
210            // Row of test-time kernel evaluations against the training set.
211            let mut k_test = vec![0.0f64; n_train];
212            for (ti, train_row) in self.training_data.iter().enumerate() {
213                k_test[ti] = self
214                    .kernel
215                    .compute(point, train_row)
216                    .map_err(KernelPcaError::from_kernel)?;
217            }
218
219            let centered: Array1<f64> = center_test_kernel(&k_test, &self.centering_stats)?;
220
221            // Embedding: z_c = sum_i centered[i] * alpha[i, c].
222            for c in 0..k {
223                let mut acc = 0.0f64;
224                for i in 0..n_train {
225                    acc += centered[i] * self.alphas[(i, c)];
226                }
227                out[(pi, c)] = acc;
228            }
229        }
230
231        Ok(out)
232    }
233}
234
235// KernelPCA is not a kernel itself — this impl simply provides a
236// descriptive error when someone tries to use it as one.
237impl<K> Kernel for KernelPCA<K>
238where
239    K: Kernel,
240{
241    fn compute(&self, _x: &[f64], _y: &[f64]) -> crate::error::Result<f64> {
242        // KernelPCA is not itself a kernel; reject any attempt to
243        // treat it as one. We only implement this trait so that the
244        // inherent `fit` can call `self.kernel.compute_matrix` without
245        // paying for another constraint on top.
246        Err(KernelError::InvalidParameter {
247            parameter: "KernelPCA".to_string(),
248            value: "not a kernel".to_string(),
249            reason: "KernelPCA is an estimator, not a Kernel; use fit/transform instead"
250                .to_string(),
251        })
252    }
253
254    fn name(&self) -> &str {
255        "KernelPCA"
256    }
257
258    fn is_psd(&self) -> bool {
259        false
260    }
261}
262
263/// The `clone_box` helper on `Kernel` used inside `fit`. Requires
264/// `Clone + 'static` which every crate kernel satisfies. We wire it in
265/// via a blanket extension trait on top of the public `Kernel` trait
266/// to avoid modifying the trait itself.
267pub(crate) trait KernelCloneExt {
268    fn clone_box(&self) -> Box<dyn Kernel>;
269}
270
271impl<K: Kernel + Clone + 'static> KernelCloneExt for K {
272    fn clone_box(&self) -> Box<dyn Kernel> {
273        Box::new(self.clone())
274    }
275}
276
277// Re-route `self.kernel.clone_box()` inside `fit` to the extension
278// trait: a blanket impl would shadow the helper trait defined earlier
279// in this file for trait objects, so we scope the helper here by
280// name.
281
282impl<K: Kernel + Clone + 'static> KernelPCA<K> {
283    /// Preferred constructor when the kernel is `Clone + 'static` —
284    /// identical signature to [`KernelPCA::new`] but re-exposed here so
285    /// that auto-derefs pick this up for the common case.
286    pub fn build(kernel: K, config: KernelPcaConfig) -> KernelPcaResult<Self> {
287        Self::new(kernel, config)
288    }
289
290    /// Fit the model on `training` — compute the Gram matrix, double
291    /// center it, eigendecompose, and cache the top-`n_components`
292    /// components for later projection.
293    ///
294    /// # Errors
295    ///
296    /// * [`KernelPcaError::InvalidInput`] for empty or ragged training
297    ///   sets.
298    /// * [`KernelPcaError::EigendecompositionFailed`] if the underlying
299    ///   solver fails.
300    /// * [`KernelPcaError::InsufficientComponents`] if the kernel matrix
301    ///   does not have enough positive eigenvalues.
302    pub fn fit(&self, training: &[Vec<f64>]) -> KernelPcaResult<FittedKernelPCA<K>> {
303        let n = training.len();
304        if n == 0 {
305            return Err(KernelPcaError::InvalidInput(
306                "KernelPCA::fit: training set must not be empty".to_string(),
307            ));
308        }
309        let d = training[0].len();
310        if d == 0 {
311            return Err(KernelPcaError::InvalidInput(
312                "KernelPCA::fit: feature dimension must be >= 1".to_string(),
313            ));
314        }
315        for (i, row) in training.iter().enumerate() {
316            if row.len() != d {
317                return Err(KernelPcaError::InvalidInput(format!(
318                    "KernelPCA::fit: training[{}] has {} features (expected {})",
319                    i,
320                    row.len(),
321                    d
322                )));
323            }
324        }
325        if self.config.n_components > n {
326            return Err(KernelPcaError::InvalidInput(format!(
327                "KernelPCA::fit: n_components ({}) cannot exceed training size ({})",
328                self.config.n_components, n
329            )));
330        }
331
332        // Compute the raw Gram matrix via the kernel's matrix routine;
333        // symmetrise to absorb any per-entry rounding drift.
334        let gram_rows = self
335            .kernel
336            .compute_matrix(training)
337            .map_err(KernelPcaError::from_kernel)?;
338        let mut gram = Array2::<f64>::zeros((n, n));
339        for i in 0..n {
340            if gram_rows[i].len() != n {
341                return Err(KernelPcaError::EigendecompositionFailed(format!(
342                    "kernel.compute_matrix returned ragged row {} (len {}, expected {})",
343                    i,
344                    gram_rows[i].len(),
345                    n
346                )));
347            }
348            for j in 0..n {
349                gram[(i, j)] = gram_rows[i][j];
350            }
351        }
352        for i in 0..n {
353            for j in (i + 1)..n {
354                let avg = 0.5 * (gram[(i, j)] + gram[(j, i)]);
355                gram[(i, j)] = avg;
356                gram[(j, i)] = avg;
357            }
358        }
359
360        // Optional double-centering.
361        let (centered, centering_stats) = if self.config.center {
362            double_center(&gram)?
363        } else {
364            // No centering requested — synthesise null stats so that
365            // `transform` can still apply the same zero-valued offsets.
366            (
367                gram.clone(),
368                KernelCenteringStats {
369                    row_means: Array1::<f64>::zeros(n),
370                    grand_mean: 0.0,
371                },
372            )
373        };
374
375        // Eigendecompose.
376        let TopKEigen {
377            eigenvalues,
378            eigenvectors,
379        } = symmetric_eigendecomp(&centered, self.config.n_components)?;
380
381        // Normalise each eigenvector `v_k` to `alpha_k = v_k / sqrt(lambda_k)`
382        // so that projections read off as `K_c(x, ·) dot alpha_k`. This is
383        // the standard KPCA scaling (Scholkopf et al. 1998, eq. (4.3)).
384        let k = self.config.n_components;
385        let mut alphas = Array2::<f64>::zeros((n, k));
386        for c in 0..k {
387            let lam = eigenvalues[c];
388            if lam <= 0.0 {
389                // symmetric_eigendecomp already filters on POSITIVITY_FLOOR,
390                // so this branch is defensive.
391                return Err(KernelPcaError::InsufficientComponents {
392                    requested: k,
393                    available: c,
394                });
395            }
396            let scale = 1.0 / lam.sqrt();
397            for r in 0..n {
398                alphas[(r, c)] = eigenvectors[(r, c)] * scale;
399            }
400        }
401
402        Ok(FittedKernelPCA {
403            kernel: KernelCloneExt::clone_box(&self.kernel),
404            alphas,
405            eigenvalues,
406            training_data: training.to_vec(),
407            centering_stats,
408            n_components: k,
409            feature_dim: d,
410            _marker: std::marker::PhantomData,
411        })
412    }
413
414    /// Convenience: fit on `training` and immediately project those
415    /// same points.
416    pub fn fit_transform(
417        &self,
418        training: &[Vec<f64>],
419    ) -> KernelPcaResult<(FittedKernelPCA<K>, Array2<f64>)> {
420        let fitted = self.fit(training)?;
421        let projected = fitted.transform(training)?;
422        Ok((fitted, projected))
423    }
424}
425
426// Users with a bespoke kernel that is *not* `Clone + 'static` should
427// wrap it in an `Arc` (which the crate's existing `SymbolicKernel`
428// already does internally).