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(¢ered, 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).