用 Rust 从头实现机器学习算法·降维:主成分分析——沿着方差最大的方向压扁数据

81 分钟阅读 rust-ml-from-scratch · 14
Rust机器学习

用 Rust 从头实现机器学习算法·降维:主成分分析——沿着方差最大的方向压扁数据

离开监督学习,进入无监督模块。降维的第一个问题:数据真的需要那么多维度吗?如果 x1x_1 和 x2x_2 强相关,一个变量就几乎决定了另一个——第二个维度大半是冗余。主成分分析(PCA) 做的事,就是为数据找一组新的正交坐标轴,让第一根轴躺在方差最大的方向上,然后理直气壮地丢掉后面的轴。

核心思想

清单的定义一句话:找到数据方差最大的方向,依次取互相正交的主成分,投影即完成降维。直觉很直白:方差大的方向信息量大(数据在这个方向上”摊开”),方差小的方向多半是噪声(数据挤成一条线)。 PCA 只关心方差、不需要标签——这是无监督的标志性特征。

两个重要性质(清单要点):主成分之间正交,输出特征天然不相关,顺带治好了多重共线性;保留多少信息用累计方差贡献率衡量——本文例子里第一主成分独占 98.2%,丢掉第二根轴几乎无损。

数学:协方差矩阵与特征分解

步骤三步走:中心化(每列减均值)→ 算协方差矩阵 Σ=1nX⊤X\Sigma = \frac{1}{n}X^{\top}X → 对 Σ\Sigma 做特征分解。特征向量是主成分方向,特征值是该方向上的方差。取前 kk 个特征向量作投影矩阵,n×2n \times 2 的数据就变成 n×kn \times k。

清单列了 EVD 与 SVD 两条技术路线,本文走 EVD 路线——几何视角清晰;SVD 路线(直接分解数据矩阵 XX)数值稳定性更好,是实践标准,思想殊途同归。实现上我们捡了个便宜:Σ\Sigma 是 2×22\times 2 对称矩阵,特征分解有闭式解:

λ±=t±d2+4b22,t=a+c, d=a−c,Σ=(abbc)\lambda_{\pm} = \frac{t \pm \sqrt{d^2 + 4b^2}}{2}, \quad t = a + c,\ d = a - c, \quad \Sigma = \begin{pmatrix} a & b \\ b & c \end{pmatrix}

特征向量由 (Σ−λI)v=0(\Sigma - \lambda I)v = 0 直接读出 v=(b,λ−a)v = (b, \lambda - a),归一化后第二根轴取第一根旋转 90°——正交与定向都有了保障。矩阵大过 2×22\times 2 就得请幂迭代或 QR 分解出山,那是另一篇文章的工程量。

Rust 实现

数据由一维潜变量生成:x1=2l+ε1x_1 = 2l + \varepsilon_1,x2=l+ε2x_2 = l + \varepsilon_2——两维强相关,理想的 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 的结构:中心化后的 XX 直接喂给 02 篇的 matmul/transpose,Σ=X⊤X/n\Sigma = X^{\top}X/n 一行矩阵运算,特征工具链全线复用。
  • reconstruct 演示降维的”往返车票”:投影到 PC1 再乘回 v1v_1 加均值——降维不是删除,是有损压缩。
  • 返回值用了一个七元组(语法角会讨论这个取舍)。
// 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 个用例:特征对回代 Av=λvAv = \lambda v、正交与单位长、对角特例、重建损失等于被丢弃的特征值)全部通过后,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

两张图:

原始点云与两个主成分轴

主成分坐标系:z2 被压缩

怎么读这些数字和图

  • λ₁ 贡献率 98.2%:一根轴装下了几乎全部信息。降维的”降”不是粗暴删列,而是旋转到信息密度最高的视角再取舍——同一批数据,换个坐标系,冗余就显形了。
  • 重建 MSE = λ₂,精确到小数点后四位:这不是巧合而是定理——丢掉的方向的方差,就是损失的平方距离。k=2 时 4.1e-32(浮点零)再次确认:不丢方向就不丢信息。这个等式让”保留多少主成分”有了可计算的答案:看累计贡献率。
  • 主成分轴图:朱橙长轴斜率 0.49 ≈ 数据的真实生成方向(x2=x1/2x_2 = x_1 / 2 → 斜率 0.5)——PCA 从噪声里把潜变量的方向捞了回来。青色短轴垂直于它,长度按 2λ2\sqrt{\lambda} 缩放,一眼看出方差悬殊。
  • 投影图的对照:左图斜向的椭圆点云,换成 (z1,z2)(z_1, z_2) 坐标后躺平——z2z_2 方向被压成薄片。若此刻丢掉 z2z_2,就是把这张薄片拍扁到横轴上,几乎无感。

优缺点与适用场景

(抄清单 3.1 原文)

  • 优点:无监督、无参数、计算快、去噪去相关。
  • 缺点:只能捕捉线性结构(非线性流形无能为力,那是核 PCA 的地盘)、对离群值敏感(协方差被极端值拖着走)、主成分可解释性差(v1v_1 的线性组合常说不清物理意义)。
  • 适用场景:数据预处理(去共线、压缩存储)、可视化(压到 2/3 维直接画图)、噪声过滤。

小结

PCA 展示了无监督学习的典型套路:不需要答案,只需要度量——这里是方差。工具仍是那套线性代数(协方差、特征分解),换了个提问方式。而降维的另一半故事是”没有答案也不要度量方向”:下一篇聚类①——k 均值与高斯混合模型,看数据如何在无标签的情况下自己抱团。