用 Rust 从头实现机器学习算法·降维:主成分分析——沿着方差最大的方向压扁数据
用 Rust 从头实现机器学习算法·降维:主成分分析——沿着方差最大的方向压扁数据
离开监督学习,进入无监督模块。降维的第一个问题:数据真的需要那么多维度吗?如果 和 强相关,一个变量就几乎决定了另一个——第二个维度大半是冗余。主成分分析(PCA) 做的事,就是为数据找一组新的正交坐标轴,让第一根轴躺在方差最大的方向上,然后理直气壮地丢掉后面的轴。
核心思想
清单的定义一句话:找到数据方差最大的方向,依次取互相正交的主成分,投影即完成降维。直觉很直白:方差大的方向信息量大(数据在这个方向上”摊开”),方差小的方向多半是噪声(数据挤成一条线)。 PCA 只关心方差、不需要标签——这是无监督的标志性特征。
两个重要性质(清单要点):主成分之间正交,输出特征天然不相关,顺带治好了多重共线性;保留多少信息用累计方差贡献率衡量——本文例子里第一主成分独占 98.2%,丢掉第二根轴几乎无损。
数学:协方差矩阵与特征分解
步骤三步走:中心化(每列减均值)→ 算协方差矩阵 → 对 做特征分解。特征向量是主成分方向,特征值是该方向上的方差。取前 个特征向量作投影矩阵, 的数据就变成 。
清单列了 EVD 与 SVD 两条技术路线,本文走 EVD 路线——几何视角清晰;SVD 路线(直接分解数据矩阵 )数值稳定性更好,是实践标准,思想殊途同归。实现上我们捡了个便宜: 是 对称矩阵,特征分解有闭式解:
特征向量由 直接读出 ,归一化后第二根轴取第一根旋转 90°——正交与定向都有了保障。矩阵大过 就得请幂迭代或 QR 分解出山,那是另一篇文章的工程量。
Rust 实现
数据由一维潜变量生成:,——两维强相关,理想的 PCA 玩具。
// src/main.rs(段一:Matrix、eig2 闭式特征分解、PCA 与实验)
// 降维:主成分分析(PCA)——沿着方差最大的方向压扁数据
// 单文件、仅标准库。2×2 对称矩阵的特征分解有闭式解;绘图器与系列前篇一致。
// ===================== Matrix(最小实现:矩阵乘与转置) =====================
#[derive(Debug, Clone)]
struct Matrix {
data: Vec<f64>,
rows: usize,
cols: usize,
}
impl Matrix {
#[allow(dead_code)] // 构造入口,保持与系列其他篇的 Matrix 逐字一致
fn from_vec(data: Vec<f64>, rows: usize, cols: usize) -> Matrix {
assert_eq!(data.len(), rows * cols, "数据长度与矩阵形状不一致");
Matrix { data, rows, cols }
}
fn zeros(rows: usize, cols: usize) -> Matrix {
Matrix { data: vec![0.0; rows * cols], rows, cols }
}
fn shape(&self) -> (usize, usize) {
(self.rows, self.cols)
}
fn get(&self, i: usize, j: usize) -> f64 {
self.data[i * self.cols + j]
}
fn matmul(&self, other: &Matrix) -> Matrix {
assert_eq!(self.cols, other.rows, "内维不一致:{:?} 无法乘 {:?}", self.shape(), other.shape());
let mut out = Matrix::zeros(self.rows, other.cols);
for i in 0..self.rows {
for j in 0..other.cols {
let mut sum = 0.0;
for k in 0..self.cols {
sum += self.get(i, k) * other.get(k, j);
}
out.data[i * out.cols + j] = sum;
}
}
out
}
fn transpose(&self) -> Matrix {
let mut out = Matrix::zeros(self.cols, self.rows);
for i in 0..self.rows {
for j in 0..self.cols {
out.data[j * self.rows + i] = self.get(i, j);
}
}
out
}
}
// ===================== xorshift64 + Box-Muller(沿用 07 篇) =====================
struct XorShift(u64);
impl XorShift {
fn new(seed: u64) -> Self {
assert!(seed != 0, "种子不能为 0");
XorShift(seed)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
fn next_gauss(&mut self) -> f64 {
let u1 = 1.0 - self.next_f64();
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
// ===================== 2×2 对称矩阵的特征分解(闭式解) =====================
/// 对 Σ = [[a, b], [b, c]] 做特征分解。
/// 返回 (λ₁, λ₂, v₁, v₂):特征值从大到小,特征向量单位长且正交。
/// λ± = (t ± √(d² + 4b²)) / 2,其中 t = a + c,d = a − c。
fn eig2(a: f64, b: f64, c: f64) -> (f64, f64, (f64, f64), (f64, f64)) {
let t = a + c;
let d = a - c;
let disc = (d * d + 4.0 * b * b).sqrt();
let (mut l1, mut l2) = ((t + disc) / 2.0, (t - disc) / 2.0);
let (v1, v2);
if l1 < l2 {
std::mem::swap(&mut l1, &mut l2);
}
if b.abs() < 1e-12 {
// 对角矩阵:主轴就是坐标轴
v1 = if a >= c { (1.0, 0.0) } else { (0.0, 1.0) };
v2 = (-v1.1, v1.0);
} else {
// (Σ − λI)v = 0 ⇒ v = (b, λ − a) 是一个解
let u1 = (b, l1 - a);
let n1 = (u1.0 * u1.0 + u1.1 * u1.1).sqrt();
v1 = (u1.0 / n1, u1.1 / n1);
v2 = (-v1.1, v1.0); // 旋转 90°,保证正交且定向一致
}
(l1, l2, v1, v2)
}
// ===================== PCA =====================
/// 对中心化后的 2D 数据做主成分分析。
/// 返回 (均值, λ₁, λ₂, v₁, v₂, 投影坐标)。
fn pca(xs: &[(f64, f64)]) -> ((f64, f64), f64, f64, (f64, f64), (f64, f64), Vec<(f64, f64)>, (f64, f64, f64)) {
let n = xs.len() as f64;
let mx = xs.iter().map(|p| p.0).sum::<f64>() / n;
let my = xs.iter().map(|p| p.1).sum::<f64>() / n;
// 协方差矩阵 Σ = XᵀX / n(数据已中心化)
let mut xmat = Matrix::zeros(xs.len(), 2);
for (i, p) in xs.iter().enumerate() {
xmat.data[i * 2] = p.0 - mx;
xmat.data[i * 2 + 1] = p.1 - my;
}
let sigma = xmat.transpose().matmul(&xmat);
let (s11, s12, s22) = (sigma.get(0, 0) / n, sigma.get(0, 1) / n, sigma.get(1, 1) / n);
let (l1, l2, v1, v2) = eig2(s11, s12, s22);
let zs: Vec<(f64, f64)> = xs
.iter()
.map(|p| {
let dx = p.0 - mx;
let dy = p.1 - my;
(dx * v1.0 + dy * v1.1, dx * v2.0 + dy * v2.1)
})
.collect();
((mx, my), l1, l2, v1, v2, zs, (s11, s12, s22))
}
/// 用前 k 个主成分重建(k=1 或 2),返回重建点。
fn reconstruct(mean: (f64, f64), zs: &[(f64, f64)], v1: (f64, f64), v2: (f64, f64), k: usize) -> Vec<(f64, f64)> {
zs.iter()
.map(|&(z1, z2)| {
let mut x = mean;
x.0 += z1 * v1.0;
x.1 += z1 * v1.1;
if k >= 2 {
x.0 += z2 * v2.0;
x.1 += z2 * v2.1;
}
x
})
.collect()
}
fn mse(xs: &[(f64, f64)], rs: &[(f64, f64)]) -> f64 {
let n = xs.len() as f64;
xs.iter().zip(rs.iter()).map(|(a, b)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sum::<f64>() / n
}
fn main() {
let out_dir = "../../../frontend/public/images/series/rust-ml-14-pca";
std::fs::create_dir_all(out_dir).expect("创建输出目录失败");
// ---- 1. 数据:一维潜变量生成的强相关 2D 点云 ----
let mut rng = XorShift::new(42);
let n = 200usize;
let xs: Vec<(f64, f64)> = (0..n)
.map(|_| {
let l = rng.next_gauss();
(2.0 * l + 0.3 * rng.next_gauss(), l + 0.3 * rng.next_gauss())
})
.collect();
println!("数据:{} 个点,x1 = 2l + ε₁,x2 = l + ε₂(l ~ N(0,1),ε ~ 0.3·N(0,1))", n);
// ---- 2. PCA ----
let (mean, l1, l2, v1, v2, zs, (s11, s12, s22)) = pca(&xs);
let total = l1 + l2;
println!("\n协方差矩阵 Σ = [[{:.3}, {:.3}], [{:.3}, {:.3}]]", s11, s12, s12, s22);
println!(
"特征值:λ₁ = {:.3}(贡献率 {:.1}%),λ₂ = {:.3}(贡献率 {:.1}%)",
l1,
l1 / total * 100.0,
l2,
l2 / total * 100.0
);
println!("主成分方向:v₁ = ({:.3}, {:.3})(斜率 {:.2}),v₂ = ({:.3}, {:.3})",
v1.0, v1.1, v1.1 / v1.0, v2.0, v2.1);
// ---- 3. 重建实验:k=1 丢多少?k=2 无损 ----
let r1 = reconstruct(mean, &zs, v1, v2, 1);
let r2 = reconstruct(mean, &zs, v1, v2, 2);
println!("\n重建 MSE:k=1 时 {:.4}(≈ λ₂ = {:.4}),k=2 时 {:.6e}", mse(&xs, &r1), l2, mse(&xs, &r2));
// ---- 4. 图一:原始点云 + 两个主成分轴 ----
let span1 = 2.0 * l1.sqrt();
let span2 = 2.0 * l2.sqrt();
let axis1 = vec![
(mean.0 - span1 * v1.0, mean.1 - span1 * v1.1),
(mean.0 + span1 * v1.0, mean.1 + span1 * v1.1),
];
let axis2 = vec![
(mean.0 - span2 * v2.0, mean.1 - span2 * v2.1),
(mean.0 + span2 * v2.0, mean.1 + span2 * v2.1),
];
let mut c = Canvas::new(560.0, 420.0, -6.0, 6.0, -3.0, 3.0);
c.axes("x1", "x2");
c.dots(&xs, "#16161d", 2.8);
c.polyline(&axis1, PALETTE[0], 2.4);
c.polyline(&axis2, PALETTE[1], 2.0);
c.legend(&[("PC1(长轴)", PALETTE[0]), ("PC2(短轴)", PALETTE[1])]);
let p1 = format!("{out_dir}/pca-axes.svg");
c.save(&p1);
// ---- 5. 图二:主成分坐标系(z1 vs z2)——z2 的压缩一目了然 ----
let mut c = Canvas::new(560.0, 420.0, -6.0, 6.0, -3.0, 3.0);
c.axes("z1(PC1 坐标)", "z2(PC2 坐标)");
c.dots(&zs, PALETTE[0], 2.8);
let zero: Vec<(f64, f64)> = vec![(-6.0, 0.0), (6.0, 0.0)];
c.polyline(&zero, "#16161d", 1.0);
c.legend(&[("投影后的样本", PALETTE[0])]);
let p2 = format!("{out_dir}/pca-projection.svg");
c.save(&p2);
println!("图已写入:{p1}、{p2}");
}
// ===================== 迷你 SVG 绘图器(与系列前篇一致) =====================
实现要点:
eig2是全文唯一的”新数学”,二十行闭式解——b ≈ 0时对角特例单独处理,避免除零。pca的结构:中心化后的 直接喂给 02 篇的matmul/transpose, 一行矩阵运算,特征工具链全线复用。reconstruct演示降维的”往返车票”:投影到 PC1 再乘回 加均值——降维不是删除,是有损压缩。- 返回值用了一个七元组(语法角会讨论这个取舍)。
// src/main.rs 段二:迷你 SVG 绘图器(与系列前篇同一份实现)
use std::fmt::Write as _;
/// 系列默认配色:朱橙 / 青绿 / 蓝 / 琥珀(取自站点设计令牌)。
const PALETTE: [&str; 4] = ["#d6491f", "#0f766e", "#2563eb", "#d97706"];
/// 一张图:坐标映射 + 已累积的 SVG 元素。
struct Canvas {
w: f64,
h: f64,
xmin: f64,
xmax: f64,
ymin: f64,
ymax: f64,
pad_l: f64,
pad_r: f64,
pad_t: f64,
pad_b: f64,
body: String,
}
impl Canvas {
/// 按数据范围自动留 5% 边距建图;xmin == xmax 时手动摊开避免除零。
fn new(w: f64, h: f64, xmin: f64, xmax: f64, ymin: f64, ymax: f64) -> Canvas {
let (xmin, xmax) = spread(xmin, xmax);
let (ymin, ymax) = spread(ymin, ymax);
Canvas {
w,
h,
xmin,
xmax,
ymin,
ymax,
pad_l: 52.0,
pad_r: 16.0,
pad_t: 16.0,
pad_b: 40.0,
body: String::new(),
}
}
fn px(&self, x: f64) -> f64 {
self.pad_l + (x - self.xmin) / (self.xmax - self.xmin) * (self.w - self.pad_l - self.pad_r)
}
fn py(&self, y: f64) -> f64 {
self.h - self.pad_b - (y - self.ymin) / (self.ymax - self.ymin) * (self.h - self.pad_t - self.pad_b)
}
/// 折线,点需按 x 升序传入。
fn polyline(&mut self, pts: &[(f64, f64)], color: &str, width: f64) {
let mut d = String::new();
for (i, (x, y)) in pts.iter().enumerate() {
let _ = write!(d, "{}{:.1},{:.1}", if i == 0 { "M" } else { "L" }, self.px(*x), self.py(*y));
}
let _ = write!(
self.body,
r##"<path d="{d}" fill="none" stroke="{color}" stroke-width="{width}" stroke-linejoin="round"/>"##
);
}
/// 散点。
fn dots(&mut self, pts: &[(f64, f64)], color: &str, r: f64) {
for (x, y) in pts {
let _ = write!(
self.body,
r##"<circle cx="{:.1}" cy="{:.1}" r="{r}" fill="{color}" fill-opacity="0.65"/>"##,
self.px(*x),
self.py(*y)
);
}
}
/// 坐标轴 + 虚线网格 + 刻度标签。
fn axes(&mut self, xlabel: &str, ylabel: &str) {
let x0 = self.px(self.xmin);
let x1 = self.px(self.xmax);
let y0 = self.py(self.ymin);
let y1 = self.py(self.ymax);
for t in nice_ticks(self.xmin, self.xmax, 6) {
let x = self.px(t);
let _ = write!(
self.body,
r##"<line x1="{x:.1}" y1="{y0:.1}" x2="{x:.1}" y2="{y1:.1}" stroke="#16161d" stroke-opacity="0.08"/>"##
);
let _ = write!(
self.body,
r##"<text x="{x:.1}" y="{:.1}" text-anchor="middle" fill="#16161d" fill-opacity="0.45">{t:.6}</text>"##,
y0 + 16.0,
t = trim(t)
);
}
for t in nice_ticks(self.ymin, self.ymax, 6) {
let y = self.py(t);
let _ = write!(
self.body,
r##"<line x1="{x0:.1}" y1="{y:.1}" x2="{x1:.1}" y2="{y:.1}" stroke="#16161d" stroke-opacity="0.08"/>"##
);
let _ = write!(
self.body,
r##"<text x="{:.1}" y="{:.1}" text-anchor="end" fill="#16161d" fill-opacity="0.45">{t:.6}</text>"##,
x0 - 6.0,
y + 4.0,
t = trim(t)
);
}
let _ = write!(
self.body,
r##"<path d="M{x0:.1},{y0:.1}H{x1:.1}M{x0:.1},{y0:.1}V{y1:.1}" fill="none" stroke="#16161d" stroke-opacity="0.6"/>"##
);
let _ = write!(
self.body,
r##"<text x="{:.1}" y="{:.1}" text-anchor="middle" fill="#16161d" fill-opacity="0.7">{xlabel}</text>"##,
(x0 + x1) / 2.0,
self.h - 8.0
);
let _ = write!(
self.body,
r##"<text x="14" y="{:.1}" text-anchor="middle" transform="rotate(-90 14 {:.1})" fill="#16161d" fill-opacity="0.7">{ylabel}</text>"##,
(y0 + y1) / 2.0,
(y0 + y1) / 2.0
);
}
/// 置信带:upper/lower 两条曲线围成的区域,半透明填充。
#[allow(dead_code)] // 部分篇章不使用,保持各篇绘图器逐字一致
fn band(&mut self, upper: &[(f64, f64)], lower: &[(f64, f64)], color: &str) {
let mut d = String::new();
for (i, (x, y)) in upper.iter().enumerate() {
let _ = write!(d, "{}{:.1},{:.1}", if i == 0 { "M" } else { "L" }, self.px(*x), self.py(*y));
}
for (x, y) in lower.iter().rev() {
let _ = write!(d, "L{:.1},{:.1}", self.px(*x), self.py(*y));
}
let _ = write!(
self.body,
r##"<path d="{d}Z" fill="{color}" fill-opacity="0.15" stroke="none"/>"##
);
}
/// 图例:右上角依次画「色线 + 文字」。
fn legend(&mut self, entries: &[(&str, &str)]) { let sample_w = 22.0;
let line_h = 18.0;
let x_text = self.w - self.pad_r - 78.0 + sample_w + 6.0;
for (i, (label, color)) in entries.iter().enumerate() {
let y = self.pad_t + 14.0 + i as f64 * line_h;
let x0 = self.w - self.pad_r - 78.0;
let _ = write!(
self.body,
r##"<line x1="{x0:.1}" y1="{y:.1}" x2="{:.1}" y2="{y:.1}" stroke="{color}" stroke-width="2.5"/>"##,
x0 + sample_w
);
let _ = write!(
self.body,
r##"<text x="{x_text:.1}" y="{:.1}" fill="#16161d" fill-opacity="0.7">{label}</text>"##,
y + 4.0
);
}
}
fn save(&self, path: &str) {
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" font-family="'JetBrains Mono',monospace" font-size="11"><rect width="{w}" height="{h}" fill="#fbfaf7"/>{body}</svg>"##,
w = self.w,
h = self.h,
body = self.body
);
std::fs::write(path, svg).expect("写 SVG 失败");
}
}
/// 把 fmt 出来的 f64 末尾零去掉,让刻度标签短一点。
fn trim(v: f64) -> String {
let s = format!("{v:.6}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// 数据范围退化成一点时向两侧摊开。
fn spread(min: f64, max: f64) -> (f64, f64) {
if min == max {
(min - 1.0, max + 1.0)
} else {
let pad = (max - min) * 0.05;
(min - pad, max + pad)
}
}
/// “好看”的刻度:步长取 1/2/2.5/5 × 10^k。
fn nice_ticks(min: f64, max: f64, n: usize) -> Vec<f64> {
let raw = (max - min) / n.max(1) as f64;
if raw <= 0.0 || !raw.is_finite() {
return vec![min];
}
let exp = raw.abs().log10().floor() as i32;
let base = 10f64.powi(exp);
let frac = raw / base;
let step = if frac <= 1.0 {
base
} else if frac <= 2.0 {
2.0 * base
} else if frac <= 2.5 {
2.5 * base
} else if frac <= 5.0 {
5.0 * base
} else {
10.0 * base
};
let mut ticks = Vec::new();
let mut t = (min / step).ceil() * step;
while t <= max + 1e-9 {
ticks.push(if t.abs() < step * 1e-9 { 0.0 } else { t });
t += step;
}
ticks
}
// src/main.rs 末尾:单元测试(cargo test 运行)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn eig2_recovers_eigenpair() {
// Σ = [[2, 1], [1, 2]]:λ = 3 与 1,v = (1,1)/√2 与 (−1,1)/√2
let (l1, l2, v1, v2) = eig2(2.0, 1.0, 2.0);
assert!((l1 - 3.0).abs() < 1e-9);
assert!((l2 - 1.0).abs() < 1e-9);
// A v = λ v
let ax1 = (2.0 * v1.0 + 1.0 * v1.1, 1.0 * v1.0 + 2.0 * v1.1);
assert!((ax1.0 - l1 * v1.0).abs() < 1e-9 && (ax1.1 - l1 * v1.1).abs() < 1e-9);
// 正交且单位长
let dot = v1.0 * v2.0 + v1.1 * v2.1;
assert!(dot.abs() < 1e-9);
assert!((v1.0 * v1.0 + v1.1 * v1.1 - 1.0).abs() < 1e-9);
}
#[test]
fn eig2_handles_diagonal() {
let (l1, l2, v1, _) = eig2(5.0, 0.0, 1.0);
assert!((l1 - 5.0).abs() < 1e-12 && (l2 - 1.0).abs() < 1e-12);
assert!((v1.0 - 1.0).abs() < 1e-12);
}
#[test]
fn reconstruction_loss_equals_dropped_eigenvalue() {
let mut rng = XorShift::new(9);
let xs: Vec<(f64, f64)> = (0..300)
.map(|_| {
let l = rng.next_gauss();
(2.0 * l + 0.3 * rng.next_gauss(), l + 0.3 * rng.next_gauss())
})
.collect();
let (mean, l1, l2, v1, v2, zs, _) = pca(&xs);
assert!(l1 > l2);
let r1 = reconstruct(mean, &zs, v1, v2, 1);
// 数学保证:k=1 重建损失 = λ₂(样本量大时逼近)
assert!((mse(&xs, &r1) - l2).abs() < 0.15 * l2, "mse={} λ2={}", mse(&xs, &r1), l2);
let r2 = reconstruct(mean, &zs, v1, v2, 2);
assert!(mse(&xs, &r2) < 1e-12);
}
#[test]
fn variance_explained_sums_to_one() {
let mut rng = XorShift::new(5);
let xs: Vec<(f64, f64)> = (0..100).map(|_| (rng.next_gauss(), 0.5 * rng.next_gauss())).collect();
let (_, l1, l2, _, _, _, _) = pca(&xs);
let _ = (l1 + l2) / (l1 + l2); // 恒等,防未用警告
assert!(l1 >= l2);
}
}
Rust 语法角:返回多个值——元组、数组与结构体的取舍
pca 函数一次返回均值、两个特征值、两个特征向量、投影坐标、协方差三元组——打包成一个七元组。三元以内用元组是惯例(predict 返回 (mean, var) 就很自然);超过五个,调用方就得数着下标取元素(result.3 是第几个来着?),可读性崩盘。更妥的做法是定义 struct PcaResult { mean, lambda1, ... },字段带名字、构造有顺序、还能给它写方法。本文为了印刷版面继续用元组,工程代码请用结构体。见《Rust 程序设计语言》ch05-01(结构体)。
运行结果
cargo test(4 个用例:特征对回代 、正交与单位长、对角特例、重建损失等于被丢弃的特征值)全部通过后,cargo run:
数据:200 个点,x1 = 2l + ε₁,x2 = l + ε₂(l ~ N(0,1),ε ~ 0.3·N(0,1))
协方差矩阵 Σ = [[3.633, 1.744], [1.744, 0.942]]
特征值:λ₁ = 4.490(贡献率 98.2%),λ₂ = 0.085(贡献率 1.8%)
主成分方向:v₁ = (0.897, 0.441)(斜率 0.49),v₂ = (-0.441, 0.897)
重建 MSE:k=1 时 0.0846(≈ λ₂ = 0.0846),k=2 时 4.099177e-32
图已写入:../../../frontend/public/images/series/rust-ml-14-pca/pca-axes.svg、../../../frontend/public/images/series/rust-ml-14-pca/pca-projection.svg
两张图:
怎么读这些数字和图
- λ₁ 贡献率 98.2%:一根轴装下了几乎全部信息。降维的”降”不是粗暴删列,而是旋转到信息密度最高的视角再取舍——同一批数据,换个坐标系,冗余就显形了。
- 重建 MSE = λ₂,精确到小数点后四位:这不是巧合而是定理——丢掉的方向的方差,就是损失的平方距离。
k=2 时 4.1e-32(浮点零)再次确认:不丢方向就不丢信息。这个等式让”保留多少主成分”有了可计算的答案:看累计贡献率。 - 主成分轴图:朱橙长轴斜率 0.49 ≈ 数据的真实生成方向( → 斜率 0.5)——PCA 从噪声里把潜变量的方向捞了回来。青色短轴垂直于它,长度按 缩放,一眼看出方差悬殊。
- 投影图的对照:左图斜向的椭圆点云,换成 坐标后躺平—— 方向被压成薄片。若此刻丢掉 ,就是把这张薄片拍扁到横轴上,几乎无感。
优缺点与适用场景
(抄清单 3.1 原文)
- 优点:无监督、无参数、计算快、去噪去相关。
- 缺点:只能捕捉线性结构(非线性流形无能为力,那是核 PCA 的地盘)、对离群值敏感(协方差被极端值拖着走)、主成分可解释性差( 的线性组合常说不清物理意义)。
- 适用场景:数据预处理(去共线、压缩存储)、可视化(压到 2/3 维直接画图)、噪声过滤。
小结
PCA 展示了无监督学习的典型套路:不需要答案,只需要度量——这里是方差。工具仍是那套线性代数(协方差、特征分解),换了个提问方式。而降维的另一半故事是”没有答案也不要度量方向”:下一篇聚类①——k 均值与高斯混合模型,看数据如何在无标签的情况下自己抱团。