【RustyML入门】2.10. 主成分分析

2.10. 主成分分析

主成分分析(PCA)在特征空间里找出一组正交的方向,也就是主轴。第一条主轴捕获数据中最大的方差。第二条主轴与第一条正交,并在此前提下捕获剩余方差中最大的一份。后面每一条主轴都遵循同样的规律。

把数据投影到前 k 条主轴上,就得到保留方差最多的 k 维线性子空间;这个子空间同时也是让重建平方误差最小的那一个。保留方差最多的子空间,恰好也是丢弃信息最少的子空间。正是这条对偶性质,让 PCA 成为压缩、去噪、给高维数据画图时最常用的第一步。

RustyML 的 PCA 对标 sklearn.decomposition.PCA:在特征矩阵上调用 fit,用 transform 得到得分,用 inverse_transform 把得分映射回特征空间。

RustyML 的 PCA 和 scikit-learn 的 PCA 有 3 处不同。第一,它只做中心化,从不做缩放。你喂给它的数值,比在一条会自动标准化的流水线里更加要紧。

第二,求解器这个设置是一个名为 SVDSolver 的枚举,提供 3 种具体策略。该选哪一种,取决于特征数量和你想要的主成分数量。

第三,每一步分解都跑在手写的纯 Rust 代码上。既没有 LAPACK,也没有 BLAS。这让各求解器之间的取舍是实打实的,而不是纸上谈兵。

2.10.1. 你要打交道的接口

PCASVDSolver 都位于 rustyml::machine_learning::decomposition 之下。这两个类型也都能通过 Prelude 拿到。这个模型是无监督的:它接收一个 f64 特征矩阵,行是样本、列是特征,不使用任何标签。

rust,ignore 复制代码
// 构造。n_components 必须 > 0,这里会检查。
PCA::new(n_components: usize) -> Result<PCA, Error>
PCA::default()                                         // n_components = 2,Full 求解器
pca.with_svd_solver(solver: SVDSolver) -> PCA          // builder,消费 self 并返回

// 学习与投影。fit 接收 &mut self。
pca.fit(&x)                -> Result<&mut PCA, Error>
pca.transform(&x)          -> Result<Array2<f64>, Error>   // (n_samples, n_components)
pca.fit_transform(&x)      -> Result<Array2<f64>, Error>
pca.inverse_transform(&scores) -> Result<Array2<f64>, Error> // (n_samples, n_features)

// 拟合后的状态。mean、components、方差以及样本/特征数量在 fit 前为 None,
// fit 后为 Some(&...)。n_components 和 svd_solver 则随时可用。
pca.get_mean()                     -> Option<&Array1<f64>>   // 每个特征的中心化均值
pca.get_components()               -> Option<&Array2<f64>>   // (n_components, n_features),行即主轴
pca.get_explained_variance()       -> Option<&Array1<f64>>
pca.get_explained_variance_ratio() -> Option<&Array1<f64>>
pca.get_singular_values()          -> Option<&Array1<f64>>
pca.get_n_components() -> usize
pca.get_svd_solver()   -> SVDSolver
pca.get_n_samples()    -> Option<usize>
pca.get_n_features()   -> Option<usize>

构造函数只检查 n_components > 0。更严的那条界限,n_components <= min(n_samples, n_features),要等到 fit 时才检查,因为它取决于数据本身。用这些 getter 来判断模型是否已经拟合:在成功的 fit 调用把值填进去之前,每一个都返回 None

get_components 把主轴作为 (n_components, n_features) 矩阵的 返回,这与 scikit-learn 的 components_ 布局一致。所以 transform 就是中心化数据乘 components.Tinverse_transform 就是得分乘 components

下面这个例子在一个小的二维数据集上跑完整流程,只保留 1 个主成分:

rust 复制代码
use ndarray::array;
use rustyml::machine_learning::decomposition::PCA;

fn main() {
    let x = array![
        [2.5, 2.4],
        [0.5, 0.7],
        [2.2, 2.9],
        [1.9, 2.2],
        [3.1, 3.0],
        [2.3, 2.7],
    ];

    // fit_transform 需要 &mut self;它就是先 fit 再对同一份数据 transform。
    let mut pca = PCA::new(1).unwrap();
    let scores = pca.fit_transform(&x).unwrap();

    println!("scores shape: {:?}", scores.shape()); // [6, 1]
    println!("mean:         {:?}", pca.get_mean().unwrap());
    println!("component:    {:?}", pca.get_components().unwrap());
    println!("variance:     {:?}", pca.get_explained_variance().unwrap());
    println!("ratio:        {:?}", pca.get_explained_variance_ratio().unwrap());
    println!("singular:     {:?}", pca.get_singular_values().unwrap());
}

fit_transform(&x) 不是一条会给出不同结果的融合捷径,它就是对同一个矩阵先调 fit 再调 transform,和手动分两步做完全一样。真正需要用两步写法的场景,是把另一个 矩阵(比如新样本)投影到一个已经拟合好的模型上。由于 fit 返回 &mut Self,你也可以直接链式调用一个 getter,不过更常见的做法还是通过 get_* 方法把状态读回来。

2.10.2. 只做中心化,不做缩放

在分解数据之前,fit 会先减去每个特征的均值,并把它存进 get_mean()。这是 fit 唯一做的预处理。它不会除以标准差,不会做白化,也不会碰各列的量纲。

transform 复用训练时存下的均值来中心化新样本,inverse_transform 再把均值加回去。均值是拟合模型的一部分,不是每次调用时临时算出来的。

跳过这一步,是把 PCA 用歪的最常见原因。方差不具备尺度不变性:把一个特征从米换算成毫米,数值会乘以 1000,方差却要乘以 1000^2,也就是一百万倍。于是 PCA 会把第一主成分整个分配给那一列。

解法是在 fit 之前先标准化特征。标准化把每一列都拉到单位方差,让 PCA 站在同一起跑线上比较它们。用 standardize 并传入 StandardizationAxis::Column。下面的例子展示了前后的差别:

rust 复制代码
use ndarray::array;
use rustyml::machine_learning::decomposition::PCA;
use rustyml::utils::standardize::{standardize, StandardizationAxis};

fn main() {
    // 特征 0 的量纲极小,特征 1 的量纲极大。信息量相同,
    // 方差却天差地别。
    let x = array![
        [0.1, 1000.0],
        [0.2, 1005.0],
        [0.3, 1002.0],
        [0.4,  998.0],
        [0.5, 1010.0],
    ];

    let mut raw = PCA::new(2).unwrap();
    raw.fit(&x).unwrap();
    println!("raw ratios:          {:?}", raw.get_explained_variance_ratio().unwrap());

    let xs = standardize(&x, StandardizationAxis::Column).unwrap();
    let mut scaled = PCA::new(2).unwrap();
    scaled.fit(&xs).unwrap();
    println!("standardized ratios: {:?}", scaled.get_explained_variance_ratio().unwrap());
}

在原始数据上,第一个比值接近 1.0:第 1 列的数值跨度淹没了第 0 列的信号,PC1 也几乎正好指向第 1 列。按列标准化之后,两个特征的贡献变得大致相当,比值也就反映出两者真实的相关结构。

要不要标准化是一个建模决策。如果特征本来就处在同一个有意义的量纲下,比如像素强度,那么只做中心化就够了。请务必有意识地做这个决定,因为 rustyml 不会替你做主。统计学上把这个选择描述为"对协方差矩阵做 PCA"还是"对相关矩阵做 PCA"。先标准化再做 PCA,等价于对相关矩阵做 PCA。

2.10.3. 选择求解器,以及背后的纯 Rust 机制

SVDSolver 决定 PCA 怎么计算主成分。这个名字不算准确:3 个变体里只有 1 个真的会构造 SVD。但接口在这 3 者之间保持一致:无论选哪一个,你拿到的主成分行、可解释方差、奇异值都一样,只是每个求解器走了不同的路线,成本和精度也各不相同。

rust,ignore 复制代码
pub enum SVDSolver {
    Full,             // 默认:对协方差矩阵做精确的特征分解
    Randomized(u64),  // 随机化范围查找,由 u64 播种
    PowerIteration,   // 用带收缩的幂迭代提取前 k 个特征对
}
变体 计算方式 适用场景
Full(默认) 构建 d x d 协方差 X^T X / (n - 1),并对其做精确分解(先 Householder 三对角化,再做隐式位移 QL)。 中小规模数据,样本和特征数大致都在 10,000 以下。精度接近机器精度,是稳妥的默认选择。
Randomized(seed) X 草绘到一个 k 宽的随机子空间,跑几轮子空间迭代,再在降维后的空间里做一次小规模 SVD。全程不构建完整的 d x d 协方差。 又大又宽的数据(10,000+ 特征),看重速度、也能接受一点近似误差。seed 让随机草图可复现。
PowerIteration 先构建协方差,再用带 Hotelling 收缩的幂迭代只提取前 k 个特征对,而不做完整的特征分解。 只需要少数几个主成分(k 远小于 d),想跳过完整的 O(d^3) 特征求解。

每种求解器的成本遵循一个清晰的规律。FullPowerIteration 都要构建稠密的 d x d 协方差,两者都要付出 O(n * d^2) 的构建开销和 O(d^2) 的存储开销。分岔发生在下一步:Full 要做一次完整的 O(d^3) 特征分解。

PowerIteration 则逐个提取 k 个特征对,每提取一个只用有限次矩阵-向量乘法,并在找下一个之前把当前这个收缩掉。当 k 远小于 d 时,这种方式在计算量上更划算。

Randomized 则完全不构建 d x d 矩阵:它的工作集是 n x k 的草图和 k x d 的降维投影,因此在 d 很大时是最省内存的选择。在小规模问题上,3 种求解器能吻合到好几位有效数字;只有当矩阵变大,近似求解器的速度优势或数值漂移才会显现出来。

rust 复制代码
use ndarray::array;
use rustyml::machine_learning::decomposition::{PCA, SVDSolver};

fn main() {
    let x = array![
        [2.5, 2.4],
        [0.5, 0.7],
        [2.2, 2.9],
        [1.9, 2.2],
        [3.1, 3.0],
        [2.3, 2.7],
        [2.0, 1.6],
        [1.0, 1.1],
    ];

    for solver in [SVDSolver::Full, SVDSolver::Randomized(42), SVDSolver::PowerIteration] {
        let mut pca = PCA::new(1).unwrap().with_svd_solver(solver);
        pca.fit(&x).unwrap();
        let sigma = pca.get_singular_values().unwrap();
        println!("{:?}: sigma_1 = {:.6}", solver, sigma[0]);
    }
}

PowerIterationRandomized 只暴露内部设置中很小的一部分。PowerIteration 没有公开的迭代次数或容差设置:它内部每个主成分最多跑 1000 次迭代,特征值容差为 1e-6,种子固定。这些值都无法通过公开 API 调节。

PowerIteration 还可能收敛失败。当数据的有效秩低于你请求的主成分数量时,就可能发生这种情况。举例来说,重复的列,或者特征之间存在精确的线性相关,都可能引发这个问题。一旦收缩步骤把可提取的方差耗尽,fit 就会返回 Error::NotConvergedFullRandomized 都不会以这种方式失败,因为它们都不依赖逐个主成分的收敛检查。

Randomized 只暴露种子这一个参数,过采样量和子空间迭代次数都是写死的。所以 PCA 的全部配置面就是 n_components 加上 with_svd_solver,这是刻意做小的。

这 3 种求解器都跑在 crate 自己的 machine_learning::linalg 模块上。这个模块直接在 ndarray 数组上重新实现了对称特征分解、SVD(单边 Jacobi)和 QR(改进的 Gram-Schmidt)。因此没有 LAPACK 依赖需要安装、链接或打包。这与 crate 其余部分纯 Rust 数值工具的做法一脉相承。底层的协方差与投影乘法用的是并行的 gemmkit 后端。

2.10.4. 解读方差,选定 n_components

fit 之后,有 3 个向量描述方差如何分布在保留的各条主轴上。get_singular_values 按降序返回中心化数据的奇异值。get_explained_variance 返回每个奇异值的平方,再除以 (n - 1):这就是每个主成分方向上的真实方差,单位与原始数据一致。

get_explained_variance_ratio 把上面这些值各自除以中心化数据的总方差。这个总方差是完整的迹,是对所有特征求和,而不只是你保留的那些。所以比值里的每一项,都是该主成分解释了总体方差中多大的比例。

这个分母的取法很关键:如果保留的主成分比特征少,这些比值加起来会小于 1,差的那部分正是你丢弃的方差。

正是这条性质,让比值成为选 n_components 的合适工具。标准做法是碎石图加累积法则:先做一次满秩分解,读出累积比值,再保留能越过某个方差目标(比如 90%、95%、99%)所需的最少主轴数。

rust 复制代码
use ndarray::array;
use rustyml::machine_learning::decomposition::PCA;

fn main() {
    // 8 个样本,3 个特征。特征 2 几乎是特征 0 的线性回声,
    // 所以数据实际上是低秩的。
    let x = array![
        [1.0, 0.2, 1.19],
        [2.0, 0.1, 2.09],
        [3.0, 0.4, 3.38],
        [4.0, 0.2, 4.19],
        [5.0, 0.5, 5.48],
        [6.0, 0.3, 6.29],
        [7.0, 0.1, 7.09],
        [8.0, 0.6, 8.58],
    ];

    // 先做满秩分解(n_components = min(n_samples, n_features) = 3),
    // 再决定实际保留几条主轴。
    let mut pca = PCA::new(3).unwrap();
    pca.fit(&x).unwrap();

    let ratio = pca.get_explained_variance_ratio().unwrap();
    let mut cumulative = 0.0;
    for (i, r) in ratio.iter().enumerate() {
        cumulative += r;
        println!("PC{}: individual = {:.4}, cumulative = {:.4}", i + 1, r, cumulative);
    }

    // 累积比值越过 95% 的最小 k。
    let mut acc = 0.0;
    let mut k = ratio.len();
    for (i, r) in ratio.iter().enumerate() {
        acc += r;
        if acc >= 0.95 {
            k = i + 1;
            break;
        }
    }
    println!("keep {} component(s) to retain >= 95% variance", k);
}

有个细节值得留意:你满秩 fit 一次是为了读出谱,如果想要更小的投影,再用选定的 k 重新 fit 一次。重新 fit 在这里很便宜,能给你一个 transform 恰好输出 k 列的模型。

如果在你的数据上重新 fit 代价很高,可以走一条捷径:满秩 fit 的前 k 个主成分行,和 k 成分 fit 得到的是同一批主轴,唯一的差别是下一节要讲的符号约定。所以你可以对满秩结果切片,而不必重新 fit。不要只拿单个孤立的比值去定 k,而要看累积曲线,它才能告诉你收益在哪里趋平。

2.10.5. 重建与 inverse_transform

inverse_transform 用公式 reconstructed = scores * components + mean 把得分映射回特征空间。当你保留全部 min(n_samples, n_features) 个主成分时,这次往返是无损的,至多有浮点误差。保留得更少时,重建就是数据在保留子空间上的正交投影。残差就是活在被丢弃主轴上的方差。度量这份残差,是给压缩代价标上一个数字的直接办法。

rust 复制代码
use ndarray::array;
use rustyml::machine_learning::decomposition::PCA;

fn main() {
    let x = array![
        [1.0, 0.2, 1.19],
        [2.0, 0.1, 2.09],
        [3.0, 0.4, 3.38],
        [4.0, 0.2, 4.19],
        [5.0, 0.5, 5.48],
        [6.0, 0.3, 6.29],
    ];

    // 只保留一条主轴,再往返回到原始的三维特征空间。
    let mut pca = PCA::new(1).unwrap();
    pca.fit(&x).unwrap();

    let scores = pca.transform(&x).unwrap();                     // (6, 1)
    let reconstructed = pca.inverse_transform(&scores).unwrap(); // (6, 3)

    // 残差的 Frobenius 范数 = 随 PC2、PC3 一起被丢掉的方差。
    let err: f64 = (&reconstructed - &x).iter().map(|d| d * d).sum::<f64>().sqrt();
    println!("scores shape:         {:?}", scores.shape());
    println!("reconstruction shape: {:?}", reconstructed.shape());
    println!("reconstruction error: {:.6}", err);
}

留意这里的形状约定,因为它和 transform 正好相反。transform 接收 n_features 列,返回 n_components 列。inverse_transform 接收 n_components 列,返回 n_features 列。传给它一个列数对不上 n_components 的矩阵,你会得到 Error::DimensionMismatch,而不是悄无声息的广播。

下一节要讲的符号修正,会把一整条主轴连同它的得分一起翻转。这让重建结果对符号翻转不敏感:乘积 scores * components 不会变,不论某条主轴有没有被取反。符号翻转永远不会破坏一次往返。

2.10.6. 确定性:符号约定与随机种子

特征向量和奇异向量只在相差一个符号的意义下唯一:-vv 张成同一条主轴。scikit-learn 用户对此并不陌生,这正是 PC 符号有时会在不同运行或不同库版本间翻转的原因。

RustyML 在分解之后把符号钉死,让结果保持确定性:必要时对每个主成分行取反,使其绝对值最大的载荷变为非负。结果就是,3 种求解器在同一份数据上,对每一条主轴的朝向都能达成一致。同一个求解器的重复运行也会达成一致。符号保持稳定,fit 无需任何额外步骤就能复现。

这带来 2 个推论。其一,这套符号约定是 rustyml 自己特有的:它取决于主成分向量本身,而不是像 scikit-learn 的 svd_flip 那样取决于 U 因子。所以同一份数据下,某个主成分的符号可能和 scikit-learn 打印出来的不同。这个差异纯属外观:主轴、它解释的方差、每一次重建都完全相同。

其二,运行间唯一真正的变动来源是 SVDSolver::Randomized:它的随机草图由你传入的 u64 播种。同一个种子给出逐比特一致的输出,不同种子给出略有差异的近似子空间。FullPowerIteration 都不带外部随机性,PowerIteration 用一个固定值在内部给自己的起始向量播种。

如果你在意整条流水线的精确可复现性,参见可复现性与随机种子。这里唯一的杠杆就是 Randomized 的种子。

拟合好的 PCAsave_to_pathload_from_path 序列化。这两个方法把整个模型(均值、主成分、方差、奇异值)写入或读出为紧凑的 postcard 二进制格式。文件扩展名无关紧要:不管你叫它什么,字节内容都是 postcard 格式。重新加载的模型,transform 出来的结果与原模型完全一致:

rust,ignore 复制代码
pca.save_to_path("pca_model.bin")?;
let loaded = PCA::load_from_path("pca_model.bin")?;
let scores = loaded.transform(&x_new)?; // 与保存前的模型完全一致

其中的机制,包括版本兼容方面的考量、二进制格式在什么情况下适合长期持久化,参见深入模型持久化

2.10.7. 错误与边界情况

PCA 会校验输入,并返回带类型的错误,而不是直接 panic。这些示例里的 .unwrap() 只是为了让代码简短。不要在生产代码里这样使用 .unwrap()。下表列出了实际会撞上的错误,外加一条 PowerIteration 求解器特有的错误:

情形 错误变体
PCA::new(0) Error::InvalidParameter
fitn_components > min(n_samples, n_features) Error::InvalidParameter
fit 时特征矩阵为空 Error::EmptyInput
fit 时样本少于 2 个 Error::InvalidInput
fittransform 时输入含 NaNInf Error::NonFinite
fit 之前调用 transforminverse_transform Error::NotFitted
transform 传入的特征数不对 Error::DimensionMismatch
inverse_transform 传入的得分列数不对 Error::DimensionMismatch
SVDSolver::PowerIteration 收敛失败,例如数据的有效秩低于 n_components Error::NotConverged

NotConverged 这种情况是 PowerIteration 特有的。FullRandomized 都不会抛出它,因为它们都不依赖严格的逐个主成分收敛检查。

2 个样本的下限并非随意定的:方差需要 n - 1 作分母,单独一行没有什么可分解的。n_components 的上限 min(n_samples, n_features) 是秩的界,你没法提取出比数据所张成的还要多的正交方向。

要得更多会引发参数错误,而不是被悄悄截断。rustyml 是对着数据检查这条界限的,而不是只对着模型本身。所以同一个 PCA::new(5) 实例,在 100 x 20 矩阵上能成功,在 3 x 4 矩阵上就会失败。

PCA 生来就是线性的:它只能找出输入特征的线性组合方向。盘踞在弯曲流形上的结构,对它而言始终是隐形的。当碎石图怎么都不肯趋平、重建在每个 k 上都很差时,这通常就是该转向非线性方法的信号。

核主成分分析把同一套方差最大化的思路搬进核特征空间。t-SNE是把非线性邻域结构做二维可视化的工具。如果目标是分开带标签的类别,而不是捕捉原始方差,就用它的有监督对应物线性判别分析:它把数据朝类别可分的方向投影,而不是朝总体散布的方向。

相关推荐
我真是泰库辣1 小时前
用TraeWork制作应用 —— 从 0 到 1 · 手把手搭建 opencode 网页对话网关
前端·后端
满怀冰雪1 小时前
17-正则化方法:Dropout、权重衰减与过拟合控制
人工智能·python·深度学习·机器学习·paddlepaddle
2603_965148111 小时前
抖音/快手直播选品:用API快速匹配爆款与低价货源
开发语言·python·自动化·api
RobinDevNotes1 小时前
轻量级动画引擎Anime.js迎来V4大版本更新
开发语言·前端·javascript·ecmascript·动画·c4前端
lv__pf1 小时前
servlet与jsp
java·开发语言·servlet
亚雷1 小时前
图解分布式架构:一个 Console,一群 Sidecar
后端·面试·程序员
篮框坏了1 小时前
配置改了不生效?我翻出 8 个坑,没一个报错
后端·spring cloud
云边有个稻草人1 小时前
KingbaseES多模架构下的MongoDB迁移方案
后端