# 2.11. 核主成分分析
`KernelPCA` 在一个隐式特征空间里运行 [PCA](./2.10._主成分分析.md)。它不分解你数据的协方差,而是分解一个中心化后的核(Gram)矩阵。这样就能捕捉到线性投影看不到的非线性结构。
核 PCA 处理的是样本两两之间的核函数取值。它的操作对象是一个 `n x n` 矩阵,而不是 `d x d` 矩阵。仅这一点,就决定了内存开销、求解器的选择,以及最主要的限制:核 PCA 没有 `inverse_transform`。
本节内容基于 `src/machine_learning/decomposition/kernel_pca.rs`、`src/machine_learning/types.rs` 中共享的核类型,以及 `tests/machine_learning/kernel_pca.rs` 里的集成测试。
核相关的这套机制,`KernelType` 枚举和 `Gamma` 系数,和驱动[支持向量机](./2.5._支持向量机.md)的是同一份代码。核 PCA 原样复用了这份代码,没有另外定义自己的核类型。
## 2.11.1. 核技巧,以及为什么普通 PCA 看不见圆环
PCA 找的是输入空间里方差最大的方向。当结构是线性的时候,这个办法很好用。当结构不是线性的时候,它就不管用了。
最经典的失败案例是 2 个同心圆环。内环半径为 `0.5`。外环半径为 `3.0`。仅凭半径就能把这 2 个类别完全分开。没有一条直线能把它们分开,所以任何线性投影也做不到。
把这些点交给 PCA,主成分只会抓住外环在角度方向上的分布。真正区分类别的半径信息,始终不会出现。
核技巧把每个点 `x` 通过一个非线性特征映射 `phi(x)` 映射出去。这会把 `x` 映射到一个维度高得多的空间。普通 PCA 随后在那个空间里运行。在这个抬升后的空间里,圆环可以变得线性可分。
核 PCA 从不直接构造 `phi(x)`。这就是这个技巧的关键。抬升空间里的 PCA 只需要形如 `phi(xi) * phi(xj)` 的内积。核函数 `K(xi, xj)` 直接算出这个内积,不需要构造 `phi`。
对 RBF 核来说,`K(x, y) = exp(-gamma * ||x - y||^2)`。它的隐式特征空间是无穷维的。每个核函数值仍然只是一个标量,你可以直接算出来。
RBF 的取值只取决于两点之间的距离。这就编码了普通 PCA 会丢弃的半径结构。这正是 RBF 核能劈开这 2 个圆环的原因。[2.11.7](#2117-实战示例分离同心圆环) 有一个可运行的示例。
## 2.11.2. 双重中心化:不那么显然的核心
抬升空间里的 PCA 需要中心化的特征:`phi_centered(xi) = phi(xi) - (1/n) * sum_k phi(xk)`。你没法直接减掉这个均值,因为你手上根本没有 `phi`。
核 PCA 真正分解的对象,是中心化特征的内积矩阵。这些内积完全可以用原始核矩阵 `K` 表达出来。展开 `phi_centered(xi) * phi_centered(xj)`,就得到双重中心化恒等式:
```text
Kc[i, j] = K[i, j] - row_means[i] - row_means[j] + overall_mean
```
`row_means[i]` 是 `K` 第 `i` 行的均值。`overall_mean` 是整个矩阵的均值。写成矩阵形式就是 `Kc = H * K * H`。`H` 是中心化矩阵 `H = I - (1/n) * J`。`I` 是单位矩阵,`J` 是 `n x n` 的全 1 矩阵。
之所以叫双重中心化,是因为 `H` 在 `K` 的两侧同时作用。它先减掉行均值和列均值,再把总体均值加回来,这样公式就不会把它减掉两次。自己实现核 PCA 时常常会漏掉那个 `+ overall_mean` 项。漏掉它会让每一次投影都出现偏差。
`fit` 实现的就是这套流程。它先算出训练核矩阵的每行均值和整体均值(`kernel_means`),然后就地把每个元素改写为 `K[i,j] - row_mean[i] - row_mean[j] + overall_mean`(`center_kernel_matrix`)。
`H * K * H` 有一个直接推论:`Kc` 的每一行求和都为零,于是投影结果的每一列均值也为零。测试 `test_centering_training_output_has_near_zero_column_means` 验证了每个投影分量的均值都落在零附近 `1e-9` 以内。
新样本需要另一套不对称的公式。很多简单粗暴的实现就在这里放弃,拒绝支持样本外变换。
投影一个新样本时,它相对训练集的那一行交叉核,需要用训练集的统计量来中心化,而不是它自己的统计量。`center_cross_kernel_matrix` 减去训练集的行均值和这条新行自身的均值,再加上训练集的整体均值。RustyML 实现了这一整套逻辑,所以对未见过的数据调用 `transform` 也能得到正确结果。参见 [2.11.6](#2116-拟合变换与样本外投影)。
## 2.11.3. 构造估计器
构造函数接收核函数和主成分数量。它会校验两者,然后返回一个 `Result`:
```rust,ignore
pub fn new(kernel: KernelType, n_components: usize) -> Result<Self, Error>
pub fn with_eigen_solver(self, eigen_solver: EigenSolver) -> Self
```
| 参数 | 类型 | 含义 |
| --- | --- | --- |
| `kernel` | `KernelType` | 核函数及其参数。RustyML 会提前校验它。参见 [2.11.4](#2114-核函数与-gamma-的选择)。 |
| `n_components` | `usize` | 保留多少个主成分。必须 `> 0`。在拟合时,还必须满足 `<= n_samples`。 |
`n_components == 0` 会立刻被拒绝,返回 [`Error::InvalidParameter`](../Chapter-01/1.6._错误处理.md) 并带上字段名。`n_components <= n_samples` 这个关系没法在构造阶段检查,因为那时候还不知道样本数。`fit` 会负责这项检查,同样返回 `Error::InvalidParameter`。失败的 `fit` 不会改动模型。测试 `test_fit_n_components_greater_than_n_samples_returns_invalid_parameter` 确认了拟合失败之后,那些读取拟合状态的 getter 仍然是 `None`。你不会得到一个改了一半的估计器。
特征值求解器默认是 `EigenSolver::Dense`。用链式构建方法 `with_eigen_solver` 来设置它。`Default` 实现给你的是一个 `gamma = 0.1` 的 RBF 核、`n_components = 2`,加上 dense 求解器:
```rust
use rustyml::machine_learning::decomposition::kernel_pca::{EigenSolver, KernelPCA};
use rustyml::machine_learning::{Gamma, KernelType};
use ndarray::array;
fn main() {
// 二维空间里的 6 个点。RBF 核,保留 2 个主成分。
let x = array![
[1.0, 0.0],
[0.0, 1.0],
[-1.0, 0.0],
[0.0, -1.0],
[2.0, 0.5],
[-0.5, 2.0],
];
let mut kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 2)
.unwrap()
.with_eigen_solver(EigenSolver::Dense);
let projected = kpca.fit_transform(&x).unwrap();
assert_eq!(projected.nrows(), 6);
assert_eq!(projected.ncols(), 2);
// 拟合后的状态通过 getter 暴露出来。
println!("kept {} components", kpca.get_n_components());
println!("training samples: {:?}", kpca.get_n_samples()); // Some(6)
let eigenvalues = kpca.get_eigenvalues().unwrap();
println!("leading eigenvalue: {}", eigenvalues[0]);
}
```
这些 getter 如实反映内部状态。`get_kernel`、`get_n_components`、`get_eigen_solver` 按值返回。`get_n_samples` 和 `get_n_features` 返回 `Option<usize>`(拟合前是 `None`)。`get_eigenvalues` 和 `get_eigenvectors` 分别返回 `Option<&Array1<f64>>` 和 `Option<&Array2<f64>>`。
存下来的特征向量,是中心化核矩阵特征分解得到的那些列。它们是每个样本对应的系数,习惯上写作 `alpha`,形状为 `n_samples x n_components`。它们不是 PCA 会给你的那种输入空间方向。核 PCA 没有什么有意义的载荷向量可看。这是在隐式空间里工作的另一面。
## 2.11.4. 核函数与 gamma 的选择
`KernelType` 有 5 个变体,和 SVC 共享:
| 变体 | 公式 | 参数 |
| --- | --- | --- |
| `Linear` | `K(x, y) = x*y` | 无 |
| `Poly { degree, gamma, coef0 }` | `(gamma*x*y + coef0)^degree` | `degree: u32`(`> 0`)、`gamma: Gamma`、`coef0: f64` |
| `RBF { gamma }` | `exp(-gamma*||x - y||^2)` | `gamma: Gamma` |
| `Sigmoid { gamma, coef0 }` | `tanh(gamma*x*y + coef0)` | `gamma: Gamma`、`coef0: f64` |
| `Cosine` | `(x*y) / (||x||*||y||)` | 无 |
`Linear` 把核 PCA 退化回普通 PCA(差别只在中心化约定上)。只把它当基线用。`RBF` 是默认选项。当你怀疑数据里有非线性的、基于距离的结构(比如圆环)时,用 `RBF`。
`Poly` 捕捉多项式交互。`Cosine` 把模长归一化掉,只保留方向。这对高维稀疏数据很有用。
`Sigmoid` 不是真正的(Mercer)核。它中心化后的 Gram 矩阵可能是不定的。这一点会牵涉到 [2.11.6](#2116-拟合变换与样本外投影) 里描述的特征值处理。
构造时的校验很严格,而且针对每种核各不相同。`Poly` 要求 `degree > 0`、`gamma` 为正的有限值、`coef0` 为有限值。`RBF` 要求 `gamma` 为正的有限值。`Sigmoid` 只要求它的参数是有限的。`Sigmoid` 接受 `gamma = 0`(测试 `test_new_sigmoid_gamma_zero_accepted`),因为零系数虽然退化,仍然是一个合法的 sigmoid。任何违规都会返回 `Error::InvalidParameter`,并点名出错的字段。
`gamma` 系数的类型是 `Gamma`。它要么是一个显式的值,要么是一条在拟合时才解析的、依赖数据的规则:
| `Gamma` 变体 | 解析为 | 何时使用 |
| --- | --- | --- |
| `Gamma::Value(v)` | `v` | 你心里已经有具体的带宽。 |
| `Gamma::Scale` | `1 / (n_features * Var(X))` | 随特征分散程度自适应的默认值(scikit-learn 的 `'scale'`)。 |
| `Gamma::Auto` | `1 / n_features` | 更简单的 `1/d` 规则(scikit-learn 的 `'auto'`)。 |
`fit` 只解析一次 `Scale` 和 `Auto`,用的是训练数据的方差和特征数。它会存下解析出来的值,这样训练矩阵和之后每一次 `transform` 调用用的都是同一个系数。如果数据方差为零(所有特征都是常量),`Gamma::Scale` 会失败,返回 `Error::InvalidInput`,因为公式里要除以它。
对 RBF 核来说,`gamma` 是带宽平方的倒数:`gamma = 1 / (2 * sigma^2)`。`gamma` 大(带宽小)时,核只看得见近邻。Gram 矩阵趋近于单位矩阵,每个点看起来都极度独特,投影会把噪声也拟合进去。`gamma` 小(带宽大)时,任意两点看起来都很相似。Gram 矩阵趋近于常数矩阵,主成分什么也抓不到。
真正有用的区间,落在近邻点的 `gamma * ||x - y||^2` 典型值接近 1 的地方。一个实用的起点是 `Gamma::Scale`。从这里出发,如果投影看起来像噪声,就调小 `gamma`。如果不同的簇挤成了一团,就调大 `gamma`。核 PCA 是无监督的,所以没有内建的交叉验证可以用来选这个值。测试套件里那个同心圆环的可分性指标(`class_separability`)就是可以拿来调参的那类下游信号。
## 2.11.5. 特征值求解器:精确与迭代
`EigenSolver` 决定 RustyML 如何从中心化核矩阵里提取前 `n_components` 个特征对。3 个求解器都是纯 Rust 的自研实现。这一步不依赖任何第二个线性代数库(`nalgebra` 只是一个开发依赖,只用于测试时的交叉验证)。
| 变体 | 策略 | 适用场景 |
| --- | --- | --- |
| `Dense`(默认) | 完整的对称特征分解(先做 Householder 三对角化,再做隐式位移 QL 迭代),也就是经典的 EISPACK/JAMA 算法组合。完整分解之后取靠前的特征对。 | 中小规模的核矩阵,你付得起完整 `O(n^3)` 分解的开销。 |
| `Lanczos` | 带完全再正交化的 Krylov 子空间迭代。把问题化简为一个小的三对角问题,再用同一个 dense 求解器精确求解。 | 大核矩阵里靠前的少数几个主成分。 |
| `PowerIteration` | 幂迭代加 Hotelling 收缩,一次求一个主成分。 | 最简单的迭代选项。当 Lanczos 显得多余时用它。 |
求解器的选择只影响速度和数值路径,不影响结果。`Dense`、`Lanczos`、`PowerIteration` 在靠前的特征值上是一致的。它们产出的投影也是一致的,至多每列相差一个符号。测试直接验证了这一点:`test_eigensolver_dense_vs_lanczos_agree` 和 `test_eigensolver_dense_vs_power_iteration_agree` 分别以 `1e-5` 和 `1e-4` 的容差,比较列范数(不看符号)和最大特征值。符号的不确定性来自特征向量本身,不是 bug。如果你需要一个固定的符号,自己在下游处理好。
迭代求解器省不了内存。3 个求解器操作的都是同一个 `n x n` 中心化核矩阵,它必须先完整存在,任何分解才能开始。当 `n_components` 远小于 `n_samples` 时,`Lanczos` 和 `PowerIteration` 能省掉完整分解的 `O(n^3)` 开销。但两者都无法避免 `O(n^2)` 的矩阵本身。[2.11.8](#2118-核-pca-做不到的事) 讨论了这个限制。
```rust,ignore
// 从大核矩阵里取少数几个主成分:跳过完整的 O(n^3) 分解。
let kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Scale }, 3)
.unwrap()
.with_eigen_solver(EigenSolver::Lanczos);
```
核 PCA 的任何求解器都不带随机性。没有种子要设。同一份数据在同一台机器上跑 2 次,输出逐位相同。测试 `test_determinism_dense_solver` 断言的是完全相等(`assert_allclose(..., 0.0)`)。这一点和 [t-SNE](./2.12._t-SNE.md) 不同,它的随机初始化确实需要[设种子](../Chapter-07/7.1._可复现性与随机种子.md)。
## 2.11.6. 拟合、变换与样本外投影
这 3 个入口都是固有方法。`KernelPCA` 也实现了 crate 的 `Fit`、`Transform`、`FitTransform` trait。这些 trait 只是转发到固有方法,所以泛型代码可以像对待 `PCA` 一样对待 `KernelPCA`:
```rust,ignore
pub fn fit<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<&mut Self, Error>
pub fn transform<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array2<f64>, Error>
pub fn fit_transform<S>(&mut self, x: &ArrayBase<S, Ix2>) -> Result<Array2<f64>, Error>
```
`fit` 至少需要 2 个样本。只有 1 行会返回 `Error::InvalidInput`。0 行会返回 `Error::EmptyInput`。非有限的输入会返回 `Error::NonFinite`。
`fit` 会解析 `gamma`,构建并中心化训练核矩阵,提取特征对,然后把后续 transform 需要的一切都存起来。这包括完整训练矩阵的一份副本,每次 `transform` 调用都会复用它。
`transform` 能投影任何与训练数据特征数相同的矩阵,包括模型从未见过的数据。它会在新样本和存下来的训练样本之间构建交叉核矩阵。它用训练集的统计量把这个矩阵中心化(参见 [2.11.2](#2112-双重中心化不那么显然的核心)),再把它投影到存下来的特征向量上。
第 `k` 个主成分上的投影坐标是 `(Kc * v_k) / sqrt(lambda_k)`。这个 `1/sqrt(lambda)` 缩放,把原始特征向量变成了归一化恰当的主成分。在 `fit` 之前调用 `transform` 会返回 `Error::NotFitted`。特征数不匹配会返回 `Error::DimensionMismatch`。
```rust
use rustyml::machine_learning::decomposition::kernel_pca::KernelPCA;
use rustyml::machine_learning::{Gamma, KernelType};
use ndarray::array;
fn main() {
let x_train = array![
[1.0, 0.0],
[0.0, 1.0],
[-1.0, 0.0],
[0.0, -1.0],
[2.0, 0.5],
[-0.5, 2.0],
[1.5, -1.5],
[-2.0, 1.0],
];
// 默认求解器就是 Dense。不用调用构建方法。
let mut kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 2).unwrap();
kpca.fit(&x_train).unwrap();
// 模型从没见过的点,特征数相同。
let x_new = array![[0.3, 0.3], [1.8, -1.2]];
let projected_new = kpca.transform(&x_new).unwrap();
assert_eq!(projected_new.nrows(), 2);
assert_eq!(projected_new.ncols(), 2);
println!("{projected_new:?}");
}
```
`fit_transform` 是一个快捷方式。它先拟合模型,再对同一份数据做变换。测试 `test_fit_transform_equals_fit_then_transform` 确认它和分 2 步调用的结果吻合到 `1e-10`。
`fit_transform` 只是写法上的便利,不是性能优化。它内部就是先调用 `fit`,再对同一个矩阵调用 `transform`。`transform` 无论如何都会从头重建一次核矩阵,所以开销和分开调用完全一样。当你需要把另一个矩阵投影过一个已经拟合好的模型时,用分 2 步的写法。
对于作用在互不相同的点上的合规 Mercer 核,中心化后的 Gram 矩阵是半正定的。每个保留下来的特征值都严格为正(测试 `test_eigenvalues_are_positive_after_fit` 也确认了它们按降序排列)。
不过核 PCA 不会因为出现非正特征值就直接判定失败。中心化 Gram 矩阵的半正定性只在舍入误差范围内成立。像 `Sigmoid` 这样的非 Mercer 核会产生真正为负的末尾特征值。`fit` 只拒绝非有限的特征值(NaN 或 Inf 会映射为 `Error::Computation`),而不会拒绝整次拟合。任何特征值算不上有意义的正数(低于相对阈值 `1e-12 * lambda_max`)的主成分,都会得到 `0.0` 的投影缩放。这会把那一列清零,而不是产生 `Inf` 或 `NaN`。
这样既保住了你要求的 `n_components` 维度,又悄悄丢掉了退化方向。测试 `test_fit_indefinite_kernel_negative_eigenvalue_is_tolerated` 用一个 Sigmoid 核驱动这条路径。它验证了出问题的那一列全为零,其余部分保持有限。
## 2.11.7. 实战示例:分离同心圆环
这正是普通 PCA 搞不定的情形。有 2 个圆环,按半径可分,却在线性意义上纠缠在一起。跑一遍 RBF 核,这 2 个类别就会落进主成分空间里可以区分的区域:
```rust
use rustyml::machine_learning::decomposition::kernel_pca::KernelPCA;
use rustyml::machine_learning::{Gamma, KernelType};
use ndarray::Array2;
use std::f64::consts::PI;
fn main() {
// 内环 r = 0.5,外环 r = 3.0。没有直线能把它们分开。
let n = 12;
let mut data: Vec<f64> = Vec::new();
for i in 0..n {
let a = 2.0 * PI * i as f64 / n as f64;
data.push(0.5 * a.cos());
data.push(0.5 * a.sin());
}
for i in 0..n {
let a = 2.0 * PI * i as f64 / n as f64;
data.push(3.0 * a.cos());
data.push(3.0 * a.sin());
}
let x = Array2::from_shape_vec((2 * n, 2), data).unwrap();
let mut kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 2).unwrap();
let proj = kpca.fit_transform(&x).unwrap();
// RBF 核编码了半径距离,所以圆环会沿着某个主成分分开。
let inner_mean: f64 = (0..n).map(|i| proj[[i, 0]]).sum::<f64>() / n as f64;
let outer_mean: f64 = (n..2 * n).map(|i| proj[[i, 0]]).sum::<f64>() / n as f64;
println!("inner-ring mean of component 0: {inner_mean:.4}");
println!("outer-ring mean of component 0: {outer_mean:.4}");
println!("gap between ring means: {:.4}", (inner_mean - outer_mean).abs());
}
```
把 `KernelType::RBF { .. }` 换成 `KernelType::Linear`,2 个环的均值之间的间隔就会塌陷。线性投影会被外环的角度变化主导。它从来不会编码半径。测试 `test_rbf_separates_radial_clusters_better_than_linear` 用一个 Fisher 风格的可分性分数把这一点量化了。它断言 RBF 投影以一个可观的优势胜过线性投影。
## 2.11.8. 核 PCA 做不到的事
**核 PCA 没有 `inverse_transform`。** 普通 [PCA](./2.10._主成分分析.md) 有。你可以把一个低维编码映射回输入空间,因为它的投影是一个带有干净转置的线性映射。
核 PCA 做不到这一点。这不是疏漏。这是原像问题(pre-image problem)。投影后的点活在隐式特征空间里。要把它反过来,你需要找到一个输入 `x`,让它的特征映射 `phi(x)` 恰好落在那个位置。
对多数核来说,尤其是 RBF,特征映射是非线性的、无穷维的,而且不满射。特征空间里任取一点,通常没有精确的原像。它只有近似的原像,需要靠另一套非线性优化才能求出来。
RustyML 没有附带这套近似算法。核 PCA 严格来说是一个前向的、单向的投影。把它用于可视化、用投影做降噪,或者当作喂给下游分类器的非线性特征环节。不要用它来做重建。
**Gram 矩阵是 `O(n^2)` 的,这才是真正的天花板。** `fit` 会构建一个 `n x n` 的 `f64` 矩阵。内存按 `8 * n^2` 字节增长,和特征数无关。这大约是 `n = 10,000` 时的 800 MB,以及 `n = 20,000` 时的 3.2 GB。
时间开销更糟。构建矩阵是 `O(n^2 * d)`,通过一次并行 GEMM 完成。dense 特征分解是 `O(n^3)`。当你只需要少数几个主成分时,换成 `Lanczos` 或 `PowerIteration` 能削减分解的开销。但没有任何办法能去掉 `O(n^2)` 的矩阵本身。
实际用起来,核 PCA 在几千个样本这个量级上还比较从容。到了几万这个量级就开始吃力了。超过这个规模之后,抽一个有代表性的子集去拟合,再对其余数据调用 `transform`(每个新 batch 都要为此付出 `O(m * n * d)`)。或者换一种从不构造完整核矩阵的方法。
每一次 `transform` 调用都拖着整个训练集。投影是相对存下来的训练样本定义的,所以对 `m` 个新点,`transform` 会重建一个 `m x n` 的交叉核矩阵。PCA 的变换开销和训练集规模无关。核 PCA 的变换开销却会随 `n` 永远增长下去。做预算时要把这一点算进去。
并行机制会在超过内部尺寸门限时自动启动,门限是按核矩阵的元素个数设定的。粗略地说,`n^2` 越过几十万个元素之后,中心化的扫描会开始并行。越过几百万个元素之后,逐元素的中心化也会并行。核 GEMM 有它自己的 FLOPs 门限。你不需要逐次调用去配置这些。可调的门限见 [7.3. 性能调优与并行](../Chapter-07/7.3._性能调优与并行.md)。
## 2.11.9. 持久化
`KernelPCA` 派生了 `Serialize` 和 `Deserialize`,并暴露出标准的那一对方法:
```rust,ignore
pub fn save_to_path(&self, path: &str) -> Result<(), Error>
pub fn load_from_path(path: &str) -> Result<Self, Error>
```
序列化用的是紧凑的 postcard 二进制格式。路径里的 `.bin`、`.dat`,或者任何其他扩展名,都只是文件名的一部分。字节内容永远是二进制的。
往返一趟之后的模型,能原样重现 `transform` 的输出。测试 `test_save_load_round_trip` 断言相等到 `1e-12`。
留意一下什么内容被序列化了。一个拟合好的核 PCA,会连同特征向量和中心化统计量一起,存下整个训练矩阵,因为 `transform` 全都要用到它们。保存下来的文件会随着训练集一起变大。这是同一套 `O(n^2)`、存样本设计的又一个后果。在你持久化一个在大语料上拟合的模型之前,记住这一点。
```rust
use rustyml::machine_learning::decomposition::kernel_pca::KernelPCA;
use rustyml::machine_learning::{Gamma, KernelType};
use ndarray::array;
use std::fs;
fn main() {
let x = array![
[1.0, 0.0],
[0.0, 1.0],
[-1.0, 0.0],
[0.0, -1.0],
[2.0, 0.5],
[-0.5, 2.0],
[1.5, -1.5],
[-2.0, 1.0],
];
let mut kpca = KernelPCA::new(KernelType::RBF { gamma: Gamma::Value(0.5) }, 2).unwrap();
kpca.fit(&x).unwrap();
let before = kpca.transform(&x).unwrap();
let path = "kpca_model.bin";
kpca.save_to_path(path).unwrap();
let loaded = KernelPCA::load_from_path(path).unwrap();
let after = loaded.transform(&x).unwrap();
assert_eq!(before.shape(), after.shape());
fs::remove_file(path).unwrap();
}
```
文件不存在时,会以 `Error::Io` 的形式出现(测试 `test_load_from_nonexistent_path_returns_io_error`)。关于错误的整体分类,见 [1.6. 错误处理](../Chapter-01/1.6._错误处理.md)。关于整个 crate 的持久化模式,见 [7.2. 深入模型持久化](../Chapter-07/7.2._深入模型持久化.md)。