2.6. 线性判别分析
线性判别分析(LDA)一次 fit 做两件事。第一,它是一个生成式分类器:把每个类别建模成一个高斯分布,各类均值不同、协方差矩阵在所有类别间共享,再用贝叶斯规则挑出最可能的标签。第二,它是一个有监督的降维器:把特征投影到最能分开各类别的方向上。RustyML 的 LDA 一次 fit 调用就同时给出这两种结果:predict、decision_function、predict_proba 服务于分类器一侧,transform 和 fit_transform 服务于投影一侧。本页覆盖这套 API、它背后的自研线性代数、这个模型依赖的高斯假设,以及协方差矩阵退化为奇异时会发生什么。
2.6.1. crate 实现了什么
RustyML 把 LDA 实现在 rustyml::machine_learning::{LDA, Shrinkage, DiscriminantSolver} 下,prelude 也重新导出了这些类型(参见 prelude)。模型吃一个 f64 特征矩阵和 i32 标签。LDA 是分类器,所以标签是整数类别 id,不是浮点数。下表列出了完整的 API 接口:
| 方法 | 签名(简化) | 返回值 |
|---|---|---|
LDA::new |
new(n_components: usize) |
Result<LDA, Error> |
LDA::default |
default() |
LDA(成分数自动确定) |
with_solver |
with_solver(DiscriminantSolver) |
LDA |
with_shrinkage |
with_shrinkage(Shrinkage) |
Result<LDA, Error> |
fit |
fit(&x, &y),其中 y: &Array1<i32> |
Result<&mut LDA, Error> |
predict |
predict(&x) |
Result<Array1<i32>, Error> |
decision_function |
decision_function(&x) |
Result<Array2<f64>, Error> |
predict_proba |
predict_proba(&x) |
Result<Array2<f64>, Error> |
transform |
transform(&x) |
Result<Array2<f64>, Error> |
fit_transform |
fit_transform(&x, &y) |
Result<Array2<f64>, Error> |
crate 没有实现 QDA(二次判别分析),只提供共享协方差、线性边界这一种变体。getter get_classes、get_priors、get_means、get_overall_mean、get_projection 在 fit 运行前返回 None。fit 运行后,每个 getter 都会把值包在 Some 里返回。查看任意一个 getter,就能知道模型是否已经训练过。
求解器枚举过去用的是不加修饰的名字 Solver,这个名字和线性模型自己的 Solver 枚举撞了。经过 prelude 的 glob 导入之后,Solver::GradientDescent 会解析到错误的那个枚举上,导致编译失败。现在两个枚举都按各自选择的对象命名:这里是 DiscriminantSolver,线性模型那边是 LeastSquaresSolver。
2.6.2. 第一个分类器
先从 2D 里 3 个彼此分得很开的簇入手。LDA::default() 会自动定下成分数,只想要标签的时候不需要自己设置成分数。
rust
use ndarray::array;
use rustyml::machine_learning::LDA;
fn main() {
// 3 个紧凑、彼此分得很开的簇,每簇 3 个样本
let x = array![
[1.0, 1.0], [1.5, 0.8], [0.8, 1.2],
[5.0, 5.0], [5.2, 4.8], [4.8, 5.2],
[9.0, 1.0], [9.2, 0.8], [8.8, 1.2],
];
let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
let mut lda = LDA::default();
lda.fit(&x, &y).unwrap();
let preds = lda.predict(&x).unwrap();
println!("predictions: {:?}", preds);
// 每个类别的判别得分(n_samples, n_classes)与 softmax 后验概率
let scores = lda.decision_function(&x).unwrap();
let proba = lda.predict_proba(&x).unwrap();
println!("scores shape: {:?}", scores.shape());
println!("proba shape: {:?}", proba.shape());
println!("class order: {:?}", lda.get_classes().unwrap());
}
predict、decision_function、predict_proba 共用同一个核心计算。对一个样本 x,LDA 为每个类别算一个线性判别得分,公式是 score_j(x) = x * Sigma^-1 * mu_j - 0.5 * mu_j * Sigma^-1 * mu_j + ln(prior_j)。这里 Sigma 是共享协方差,mu_j 是类别均值,prior_j 是该类别在训练集里的频率。decision_function 把这些原始得分以 (n_samples, n_classes) 矩阵的形式返回。predict 取每一行得分的 argmax,再映射回原始标签。predict_proba 对同一批得分做数值稳定的逐行 softmax,因此每一行的和为 1。decision_function 和 predict_proba 的列顺序跟着 get_classes() 走,而 get_classes() 按升序排列标签。不要假设这个顺序跟标签在 y 里首次出现的顺序一致。
这里的先验就是纯粹的类别频率(n_class / n_samples)。对这个类别均衡的数据集来说,每个先验都是 1/3。类别不均衡会改变这一点:ln(prior_j) 这一项会把边界推向更稀有的类别,一个贝叶斯分类器本该如此。想要先验相等,就把训练集配平------LDA 的构造器没有 priors= 这样的覆盖项。
2.6.3. 有监督投影与 n_classes - 1 上限
投影这一侧才是 LDA 区别于 PCA 的地方。LDA 找的是让类间散度相对类内散度最大化的方向。只有 n_classes - 1 个方向具备非零区分度,因为类间散度矩阵的秩至多为 n_classes - 1。crate 强制执行这条上限:可用成分数是 min(n_classes - 1, n_features),在 fit 时定下来。LDA::default()(或 n_components = None)取这个上限;LDA::new(k) 精确请求 k 个成分,若 k 超过上限则在 fit 时失败。
所以画一张 2D 散点图,至少需要 3 个类别。两个类别只给得出一根判别轴。fit_transform 一次调用就完成训练加投影:
rust
use ndarray::array;
use rustyml::machine_learning::{DiscriminantSolver, LDA, Shrinkage};
fn main() {
let x = array![
[1.0, 1.0], [1.5, 0.8], [0.8, 1.2],
[5.0, 5.0], [5.2, 4.8], [4.8, 5.2],
[9.0, 1.0], [9.2, 0.8], [8.8, 1.2],
];
let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
// 3 个类别 -> 至多 2 根判别轴;直接投到 2D
let mut lda = LDA::new(2)
.unwrap()
.with_solver(DiscriminantSolver::Eigen)
.with_shrinkage(Shrinkage::Auto)
.unwrap();
let coords = lda.fit_transform(&x, &y).unwrap(); // (9, 2)
println!("embedding shape: {:?}", coords.shape());
for (row, &label) in coords.outer_iter().zip(y.iter()) {
println!("class {label}: ({:.3}, {:.3})", row[0], row[1]);
}
// 投影矩阵把 n_features -> n_components
let w = lda.get_projection().unwrap();
println!("projection shape: {:?}", w.shape()); // [2, 2]
}
text
embedding shape: [9, 2]
... one line per sample: class <label>: (<axis 1>, <axis 2>) ...
projection shape: [2, 2]
投影矩阵 W 的形状是 (n_features, n_components)。transform 算的是 (x - xbar) * W。这里 xbar 是整个训练矩阵的均值,对应 scikit-learn 的 xbar_,可以通过 get_overall_mean() 读出来。RustyML 严格对齐 scikit-learn 的 (X - xbar_) @ scalings_ 公式。这次对齐带来两处细节,而且都是最近才改的。第一,投影会按训练均值做中心化 ,因此变换后的训练集落在原点附近,而不是落在原始特征值本身的位置。第二,各条轴保留白化产生的尺度,RustyML 不会把它们重新归一化到单位 L2 范数。这个尺度不是装饰性的:它让投影后的数据具有单位类内协方差,这正是整套构造要达成的目标。如果你以前是从一个单位范数的旧轴上读坐标的,现在会看到不同的偏移和不同的尺度。
RustyML 按类别区分度给各列排序,广义特征值最大的排在最前,所以第一个成分就是那根最具区分力的轴。如果你之后想从同一次拟合里做 1D 降维,这个排序会有用。一根判别轴和它的反向对分类的效果完全一样,所以任何一列的符号都是任意的。把画出来的坐标轴方向当成没有意义,不要从中读出方向性。
transform 和 fit_transform 执行的是同一个降维步骤。fit_transform(&x, &y) 用一次调用依次跑 fit(&x, &y) 和 transform(&x)。
2.6.4. 求解器与自研线性代数
DiscriminantSolver 决定 RustyML 怎么为分类器的评分系数 Sigma^-1 * mu_c 求共享协方差的逆。这是一个重要、也极易被忽略的事实:求解器的选择只影响 predict、decision_function、predict_proba。transform 投影不依赖求解器,RustyML 始终从类内协方差的对称特征分解里得到它。换求解器会改变决策边界的数值,但永远不会改变 2D 嵌入。
DiscriminantSolver |
怎么评分 | 什么时候用它 |
|---|---|---|
SVD(默认) |
对 Sigma 做 SVD 伪逆,截断小奇异值 |
通用默认,能很好地处理秩亏 |
Eigen |
对称特征分解求逆,极小特征值归零 | 协方差良态时使用,效果和 SVD 相当 |
LSQR |
迭代式 Paige-Saunders 最小二乘,不形成显式逆 | 维度极高,显式求逆会浪费内存 |
3 种求解器在分得开的数据上得到的标签完全一致,差别只在于它们怎么应对病态的 Sigma。SVD 和 Eigen 都会构造一个伪逆,丢掉那些奇异值或特征值低于相对容差的方向,因此秩亏的协方差会优雅地退化,而不是直接失败。LSQR 完全绕开求逆,直接迭代求解每个 Sigma * coef_c = mu_c 方程组,这在 n_features 很大时能省内存。
线性代数完全是自研的纯 Rust 实现,RustyML 不带 LAPACK 或 nalgebra 依赖(见优先自研数值实现)。对称特征分解用的是 Householder 三对角化,接上隐式位移 QL 迭代,也就是经典的 EISPACK tred2 和 tql2 这对组合。SVD 用的是单边 Jacobi 方法,能把小奇异值算到很高的相对精度。LSQR 用的是带滚动 Givens 旋转的 Golub-Kahan 双对角化。在 LDA 产生的中小规模协方差矩阵上,这些方法都能确定到机器精度,这也是 LDA 不需要随机种子的原因。
2.6.5. 收缩与协方差正则化
每个特征只有很少几个样本时,原始样本协方差是个带噪的估计,它的逆会把噪声放大。收缩(shrinkage)把 Sigma 往一个缩放后的单位矩阵拉,用一点点偏差换来方差的大幅下降。Shrinkage 有两种形式:
Shrinkage::Auto:Ledoit-Wolf 闭式最优强度,RustyML 直接从数据算出来,没有调参旋钮。Shrinkage::Manual(alpha):显式给一个落在[0, 1]里的alpha。取0就是不收缩,取1就完全收缩到单位矩阵目标。with_shrinkage会校验alpha,如果它落在[0, 1]之外或者不是有限值,就返回Error::InvalidParameter------这正是with_shrinkage返回Result的原因。
这里有两个细节值得注意。第一,不管你怎么选收缩,RustyML 总会给协方差加一条极小的固定对角脊,大约是平均方差的 1e-6 倍,纯粹是为了让分解保持稳定。所以 Shrinkage::Manual(0.0) 和完全不收缩会得到一模一样的结果。第二,这里的收缩和求解器是相互独立的,这是和 scikit-learn 一处实打实的分歧:scikit-learn 在它的 svd 求解器下拒绝收缩,RustyML 则在任何求解器跑之前就把收缩施加到协方差上,所以 DiscriminantSolver::SVD 配 Shrinkage::Auto 完全合法,而且往往是最好的组合。只要遇到共线特征或者样本-特征比很吃紧的情况,就把 Shrinkage::Auto 当默认选择。
2.6.6. 假设,以及假设失效时的症状
LDA 的最优性建立在两条假设上:每个类别都服从高斯分布,且所有类别共享同一个协方差矩阵。共享协方差正是让决策边界成为线性的原因,因为二次项相互抵消了。这些假设成立时,LDA 很省数据:它是把所有类别汇到一起估一个协方差,而不是每个类别各估一个。
假设失效时,症状很具体。假设各类别的协方差确实不同(这种情况叫异方差:一个类别是紧致的一团,另一个是散开的一片云),那么真正的贝叶斯边界就是二次的。LDA 被逼出来的线性边界会在离散程度不同的区域里系统性地误分类:那个更宽的类别往往会在边界附近占据更多地盘。crate 里没有 QDA 可以替代,所以对强异方差数据,改用非线性分类器,比如决策树、带 RBF 核的 SVM,或者 KNN 分类器。一个类别如果是多峰的或明显非高斯的,也可能破坏投影,即便分类器还能给出可用的标签。在信任这个降维结果之前,先看一眼 transform 的散点图。
2.6.7. 奇异协方差与高维
这一节讲的是特征共线,或者 n_features 超过 n_samples、协方差因此变得奇异时会发生什么。RustyML 不会 抛一个专门的奇异矩阵错误。crate 对 n_features 相对 n_samples 的关系没有设防。唯一的样本数检查是:n_samples 必须大于 n_classes,且每个类别至少要有 2 个样本。秩亏的协方差通过两条途径被吸收:常开的对角脊把协方差往可逆方向推;SVD 和 Eigen 伪逆,以及投影内部的白化步骤,会把特征值低于相对容差的方向直接归零,转而投影到协方差的非零子空间上,而不是去除以一个接近 0 的数。
rust
use ndarray::array;
use rustyml::machine_learning::LDA;
fn main() {
// 第三列等于前两列之和,所以特征矩阵秩亏,
// 共享协方差因此奇异
let x = array![
[1.0, 2.0, 3.0],
[1.5, 2.5, 4.0],
[2.0, 3.0, 5.0],
[6.0, 5.0, 11.0],
[6.5, 4.5, 11.0],
[7.0, 5.5, 12.5],
];
let y = array![0, 0, 0, 1, 1, 1];
// 没有奇异矩阵错误:对角脊和伪逆吸收了秩亏
let mut lda = LDA::default();
lda.fit(&x, &y).unwrap();
let coords = lda.transform(&x).unwrap(); // (6, 1):2 个类别 -> 1 个成分
assert!(coords.iter().all(|v| v.is_finite()));
println!("reduced to {:?}, all finite", coords.shape());
}
投影还剩一种失败模式:Error::Computation,其 context 字段是 "Discriminant direction norm too small for stable projection"。只有当某根被选中的判别轴相对最大那根轴塌缩成一个近乎零的向量时,RustyML 才会抛出这个错误。这道判据变成相对的,是因为 RustyML 不再把各条轴归一化到单位长度。这种失败发生在你要求的成分数超过了数据在其退化子空间里能支撑的数量时。把它当成一次硬性的数值失败,而不是校验层面的疏漏。想解决它,就减少 n_components,或者加大正则化力度。当 n_features 大于 n_samples,或者数据严重共线时,不要只依赖那条固定的 1e-6 对角脊,改用 Shrinkage::Auto。它对协方差的调理比那个最小稳定器好得多,也是能用的投影和被噪声方向主导的投影之间的分水岭。
2.6.8. LDA、PCA 与逻辑回归的对比
有 3 个相关方法值得直接放在一起比较,因为在它们之间做选择往往才是真正的问题。
| 有监督? | 边界 / 轴 | 假设 | 成分上限 | |
|---|---|---|---|---|
| LDA | 是(用标签) | 类别分离度最大的方向 | 高斯类别,共享协方差 | n_classes - 1 |
| PCA | 否(忽略标签) | 总方差最大的方向 | 无(只用二阶矩) | n_features |
| 逻辑回归 | 是 | 只有线性决策边界 | 对类别形状无假设 | (不是降维器) |
PCA 最大化方差,从头到尾都不看 y。所以如果数据的离散主要落在那个最不能 分开类别的方向上,它的头部成分也会毫不犹豫地和这个方向对齐。LDA 直接最大化分离度,但被卡在 n_classes - 1 根轴上。当你想要一个有监督的低维视图,或者一个快速的生成式分类器、并且类别大致高斯时,用 LDA。当你想要无监督压缩,或者需要多于 n_classes - 1 个维度时,用 PCA。
和逻辑回归的分野在于生成式对判别式。LDA 建模类别条件密度,再套用贝叶斯规则。逻辑回归直接建模 P(y | x),对每个类别的形状不作任何假设。当高斯和等协方差假设确实成立时,LDA 能用更少的样本收敛到一个好边界。假设不成立时,逻辑回归是更靠得住的线性分类器,因为它从未假设过类别的形状。一种常见的做法是两个模型都保留,让留出集上的准确率------用分类指标量出来------来决定用哪个。
2.6.9. 错误、持久化与可复现性
错误面遵循 crate 全局的错误处理约定。LDA::new(0) 返回 Error::InvalidParameter,因为成分数必须为正。越界的 Shrinkage::Manual 同样返回 InvalidParameter,在构建时就被抓住。结构性问题在 fit 时浮现:空的特征矩阵,或者特征列数为 0 的矩阵,返回 Error::EmptyInput;类别少于 2 个、n_samples 不大于 n_classes、某个类别只有 1 个样本、n_components 过大,这些情况全都返回 Error::InvalidInput;x 里出现非有限值返回 Error::NonFinite;x/y 长度不一致返回 Error::DimensionMismatch。到 predict 或 transform 时,空的输入矩阵同样返回 Error::EmptyInput,特征数不对返回 Error::DimensionMismatch,调用未拟合的模型返回 Error::NotFitted("LDA")。
rust
use ndarray::array;
use rustyml::error::Error;
use rustyml::machine_learning::{LDA, Shrinkage};
fn main() {
// 手动收缩必须落在 [0, 1] 里,RustyML 会在你构建模型时拒绝 1.5
match LDA::new(1).unwrap().with_shrinkage(Shrinkage::Manual(1.5)) {
Err(Error::InvalidParameter { name, .. }) => println!("rejected parameter: {name}"),
_ => unreachable!(),
}
// n_components 只有在类别数已知后才有界,所以一个过大的请求
// 能通过构造器、却在 fit 时失败
let x = array![
[1.0, 1.0], [1.5, 0.8], [0.8, 1.2],
[5.0, 5.0], [5.2, 4.8], [4.8, 5.2],
];
let y = array![0, 0, 0, 1, 1, 1]; // 2 个类别 -> max_components = 1
let mut lda = LDA::new(2).unwrap();
match lda.fit(&x, &y) {
Err(Error::InvalidInput(msg)) => println!("fit rejected: {msg}"),
_ => unreachable!(),
}
}
一个已拟合的 LDA 用 save_to_path 和 load_from_path 序列化,两者都走 postcard 二进制格式。.bin 扩展名只是个文件名习惯,不管你选什么扩展名,格式始终是二进制的。这趟往返会还原类别、先验、均值、训练集总体均值、投影,以及缓存的评分系数。加载回来的模型不用重新拟合,就能给出一模一样的预测和变换结果。那个总体均值是 transform 开始做中心化时格式新增的字段。旧版本 RustyML 保存的二进制块加载不了,需要用当前版本重新拟合、重新保存。格式细节见深入模型持久化。
rust
use ndarray::array;
use rustyml::machine_learning::LDA;
use std::fs;
fn main() {
let x = array![
[1.0, 1.0], [1.5, 0.8], [0.8, 1.2],
[5.0, 5.0], [5.2, 4.8], [4.8, 5.2],
[9.0, 1.0], [9.2, 0.8], [8.8, 1.2],
];
let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
let mut lda = LDA::new(2).unwrap();
lda.fit(&x, &y).unwrap();
let before = lda.predict(&x).unwrap();
let path = "lda_model.bin";
lda.save_to_path(path).unwrap();
let loaded = LDA::load_from_path(path).unwrap();
let after = loaded.predict(&x).unwrap();
assert_eq!(before, after);
fs::remove_file(path).unwrap();
println!("round-trip predictions identical");
}
LDA 是完全确定的:它不抽任何随机数,所以同样的数据每次都产出同样的投影、同样的系数、同样的标签,不需要设种子,这一点和可复现性与随机种子里那些随机模型不同。唯一的运行间自由度是每根判别轴那个任意的符号,这是特征向量的数学性质,不是不确定性的表现。拟合会在工作量越过 crate 标定的阈值后,把逐类别统计和最后的标签扫描并行化。并行永远不会改变结果,它只改变结果送达的速度。更多内容见性能调优与并行。