用 Rust 从头实现机器学习算法·聚类②:DBSCAN 与谱聚类——月牙形数据的两位奇兵

97 分钟阅读 rust-ml-from-scratch · 16
Rust机器学习

用 Rust 从头实现机器学习算法·聚类②:DBSCAN 与谱聚类——月牙形数据的两位奇兵

k 均值与 GMM 的簇都是”凸的蛋形”——遇到月牙、环形这类纠缠结构就束手无策。本篇的两位主角从完全不同的假设出发:DBSCAN 用密度(簇是高密度点的连通区域),谱聚类用图论(把聚类变成图切分)。数据是交错的月牙——上一篇的椭圆簇在这里只会让两簇被切得支离破碎。

核心思想

DBSCAN(Density-Based Spatial Clustering)的两个超参数:ε\varepsilon(邻域半径)与 MinPts(成为核心点所需的最小邻居数)。点的三种身份:核心点(邻居 ≥ MinPts)、边界点(在核心点邻域内但自己不达标)、噪声点(谁也管不着)。算法从每个未访问的核心点出发,把密度可达的点层层扩展成簇——无需预设簇数,噪声自动现形,任意形状的簇都能长出来。

谱聚类三步走(清单 4.6):① 构图——每个点连 kk 个最近邻,边权随距离指数衰减;② 谱嵌入——算图拉普拉斯 L=D−WL = D - W 的前 kk 个特征向量(DD 是度矩阵),把样本嵌入到 kk 维谱空间;③ 收尾——在嵌入空间里跑 k 均值。直觉:切图(Normalized Cut)想让”簇内边权大、簇间边权小”,其松弛解恰好是拉普拉斯的低维特征向量——月牙两弧之间的”桥”很窄,一切就开。

数学:密度可达与拉普拉斯

DBSCAN 没有目标函数,只有可达性定义:xjx_j 从 xix_i 密度直达若 xjx_j 在核心点 xix_i 的 ε\varepsilon 邻域内;密度可达是直达的传递闭包。簇 = 互相密度可达的最大点集。这解释了它的性格:对异常值免疫(噪声不入簇)、对密度不均敏感(一个 ε\varepsilon 管不住胖瘦两簇)。

谱聚类的对象是归一化拉普拉斯 Lsym=I−D−1/2WD−1/2L_{\text{sym}} = I - D^{-1/2} W D^{-1/2}。清单说它的本质是 Normalized Cut 的松弛解——这里给出操作版:LsymL_{\text{sym}} 的最小特征向量(对应 M=D−1/2WD−1/2M = D^{-1/2} W D^{-1/2} 的最大特征向量)把图”劈”成联系最弱的两半;前两个特征向量组成的嵌入矩阵按行归一化后,普通 k 均值就能在谱空间里把纠缠的簇分开。

Rust 实现

// src/main.rs(段一:月牙数据、k 均值对照、DBSCAN、谱聚类与主程序)
// 聚类②:DBSCAN 与谱聚类——月牙形数据的两位奇兵
// 单文件、仅标准库。谱聚类用幂迭代求特征向量;绘图器与系列前篇一致。

// ===================== 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()
    }
}

// ===================== 数据:两个月牙 =====================

/// 上弧圆心 (0, 0),下弧圆心 (1, 0.3),加小噪声——交错月牙。
fn make_moons(rng: &mut XorShift, n: usize, sigma: f64) -> (Vec<(f64, f64)>, Vec<usize>) {
    let mut xs = Vec::with_capacity(n * 2);
    let mut ys = Vec::with_capacity(n * 2);
    for _ in 0..n {
        let t = 0.2 + (std::f64::consts::PI - 0.4) * rng.next_f64();
        xs.push((t.cos() + sigma * rng.next_gauss(), t.sin() + sigma * rng.next_gauss()));
        ys.push(0);
    }
    for _ in 0..n {
        let t = 0.2 + (std::f64::consts::PI - 0.4) * rng.next_f64();
        xs.push((1.0 - t.cos() + sigma * rng.next_gauss(), 0.3 - t.sin() + sigma * rng.next_gauss()));
        ys.push(1);
    }
    (xs, ys)
}

fn dist2(a: (f64, f64), b: (f64, f64)) -> f64 {
    (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)
}

// ===================== k 均值(对比用,沿用 15 篇) =====================

fn kmeans(xs: &[(f64, f64)], k: usize, iters: usize, rng: &mut XorShift) -> Vec<usize> {
    let mut centroids: Vec<(f64, f64)> = vec![xs[(rng.next_f64() * xs.len() as f64) as usize]];
    while centroids.len() < k {
        let d2: Vec<f64> = xs
            .iter()
            .map(|x| centroids.iter().map(|c| dist2(*x, *c)).fold(f64::INFINITY, f64::min))
            .collect();
        let total: f64 = d2.iter().sum();
        let mut u = rng.next_f64() * total;
        let mut pick = 0;
        for (i, d) in d2.iter().enumerate() {
            u -= d;
            if u <= 0.0 {
                pick = i;
                break;
            }
        }
        centroids.push(xs[pick]);
    }
    let mut assign = vec![0usize; xs.len()];
    for _ in 0..iters {
        let mut moved = false;
        for (i, x) in xs.iter().enumerate() {
            let a = (0..k)
                .min_by(|&a, &b| dist2(*x, centroids[a]).partial_cmp(&dist2(*x, centroids[b])).unwrap())
                .unwrap();
            if assign[i] != a {
                assign[i] = a;
                moved = true;
            }
        }
        let mut sum = vec![(0.0, 0.0); k];
        let mut cnt = vec![0usize; k];
        for (x, &a) in xs.iter().zip(assign.iter()) {
            sum[a].0 += x.0;
            sum[a].1 += x.1;
            cnt[a] += 1;
        }
        for c in 0..k {
            if cnt[c] > 0 {
                centroids[c] = (sum[c].0 / cnt[c] as f64, sum[c].1 / cnt[c] as f64);
            }
        }
        if !moved {
            break;
        }
    }
    assign
}

// ===================== DBSCAN(密度连通) =====================

/// 返回每个点的簇标签:0..k-1 为簇,-1 为噪声。
fn dbscan(xs: &[(f64, f64)], eps: f64, min_pts: usize) -> Vec<i32> {
    let n = xs.len();
    let labels = vec![-1i32; n];
    let mut labels = labels;
    let mut cluster = 0i32;
    for i in 0..n {
        if labels[i] != -1 {
            continue; // 已处理
        }
        // ε 邻域
        let neighbors: Vec<usize> = (0..n).filter(|&j| dist2(xs[i], xs[j]) <= eps * eps).collect();
        if neighbors.len() < min_pts {
            labels[i] = -1; // 暂记噪声,可能被后来的核心点收留
            continue;
        }
        // 扩展簇:BFS
        labels[i] = cluster;
        let mut queue: Vec<usize> = neighbors;
        while let Some(p) = queue.pop() {
            if labels[p] == -1 {
                labels[p] = cluster;
            }
            if labels[p] != cluster {
                continue; // 已属其它簇
            }
            let p_neighbors: Vec<usize> = (0..n).filter(|&j| dist2(xs[p], xs[j]) <= eps * eps).collect();
            if p_neighbors.len() >= min_pts {
                for &q in &p_neighbors {
                    if labels[q] == -1 || labels[q] == -2 {
                        queue.push(q);
                    }
                }
            }
        }
        cluster += 1;
    }
    labels
}

// ===================== 谱聚类(kNN 图 + 幂迭代) =====================

/// 幂迭代求算子 matvec 的主特征向量(|λ| 最大)。
/// 每次迭代做与已有特征向量的正交化(deflation),用于取第 2、3 个。
fn power_iteration(
    n: usize,
    matvec: &dyn Fn(&[f64]) -> Vec<f64>,
    orthogonal_to: &[Vec<f64>],
    rng: &mut XorShift,
    iters: usize,
) -> (f64, Vec<f64>) {
    let mut v: Vec<f64> = (0..n).map(|_| rng.next_gauss()).collect();
    let norm = |v: &[f64]| v.iter().map(|x| x * x).sum::<f64>().sqrt();
    let mut l = v.iter().map(|x| x * x).sum::<f64>().sqrt();
    for _ in 0..iters {
        let mut y = matvec(&v);
        // deflation:与已知特征向量正交化
        for u in orthogonal_to {
            let dot: f64 = y.iter().zip(u.iter()).map(|(a, b)| a * b).sum();
            for k in 0..n {
                y[k] -= dot * u[k];
            }
        }
        let ny = norm(&y);
        if ny < 1e-12 {
            break;
        }
        for k in 0..n {
            v[k] = y[k] / ny;
        }
        l = ny;
    }
    (l, v)
}

/// 谱聚类三步骤:构图 → 拉普拉斯谱嵌入 → k 均值。
/// 返回簇分配。
fn spectral_cluster(xs: &[(f64, f64)], k: usize, knn: usize, sigma: f64, rng: &mut XorShift) -> Vec<usize> {
    let n = xs.len();
    // 1) k 近邻图(双向合并):权重 w_ij = exp(−‖xi−xj‖² / 2σ²)
    let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
    for i in 0..n {
        let mut order: Vec<usize> = (0..n).filter(|&j| j != i).collect();
        order.sort_by(|&a, &b| dist2(xs[i], xs[a]).partial_cmp(&dist2(xs[i], xs[b])).unwrap());
        for &j in order.iter().take(knn) {
            let w = (-dist2(xs[i], xs[j]) / (2.0 * sigma * sigma)).exp();
            adj[i].push((j, w));
            adj[j].push((i, w)); // 对称化
        }
    }
    // 度 d_i = Σ_j w_ij;sqrt_d = D^{-1/2}
    let mut sqrt_d = vec![0.0; n];
    for i in 0..n {
        let s: f64 = adj[i].iter().map(|&(_, w)| w).sum();
        sqrt_d[i] = s.sqrt().max(1e-12);
    }
    // 2) M = D^{-1/2} W D^{-1/2} 的稀疏矩阵-向量乘
    let matvec = |v: &[f64]| -> Vec<f64> {
        let mut y = vec![0.0; n];
        for i in 0..n {
            let mut s = 0.0;
            for &(j, w) in &adj[i] {
                s += w * v[j] / sqrt_d[j];
            }
            y[i] = s / sqrt_d[i];
        }
        y
    };
    // M 的主特征向量 = L_sym = I − M 的最小特征向量
    let (_, v1) = power_iteration(n, &matvec, &[], rng, 200);
    let (_, v2) = power_iteration(n, &matvec, &[v1.clone()], rng, 200);
    // 3) 嵌入 U = [v1, v2],行归一化后 k 均值
    let mut embedded: Vec<(f64, f64)> = (0..n).map(|i| (v1[i], v2[i])).collect();
    for e in &mut embedded {
        let nrm = (e.0 * e.0 + e.1 * e.1).sqrt();
        if nrm > 1e-12 {
            e.0 /= nrm;
            e.1 /= nrm;
        }
    }
    kmeans(&embedded, k, 100, rng)
}

// ===================== 主程序 =====================

fn main() {
    let out_dir = "../../../frontend/public/images/series/rust-ml-16-density-spectral";
    std::fs::create_dir_all(out_dir).expect("创建输出目录失败");

    // ---- 1. 月牙数据 ----
    let mut rng = XorShift::new(42);
    let (xs, truth) = make_moons(&mut rng, 100, 0.06);
    println!("数据:{} 个点的交错月牙(σ = 0.06)", xs.len());

    // ---- 2. 对照组:k 均值在月牙上的失败 ----
    let km = kmeans(&xs, 2, 100, &mut rng);
    let km_acc = cluster_acc(&km, &truth);
    println!("\n[对照] k 均值(球形假设):调整兰德后 acc = {:.3}", km_acc);

    // ---- 3. DBSCAN:ε 扫描 ----
    println!("\n[DBSCAN]  MinPts = 5,ε 扫描:");
    println!("      ε      簇数   噪声点");
    let mut best_eps = (0.0f64, i32::MIN, usize::MAX);
    for eps in [0.10, 0.15, 0.20, 0.25, 0.30] {
        let lb = dbscan(&xs, eps, 5);
        let clusters = lb.iter().copied().filter(|&l| l >= 0).max().unwrap_or(-1) + 1;
        let noise = lb.iter().filter(|&&l| l == -1).count();
        println!("  {:.2}   {:>4}   {:>5}", eps, clusters, noise);
        if clusters == 2 && noise < best_eps.2 {
            best_eps = (eps, clusters, noise);
        }
    }
    let db = dbscan(&xs, best_eps.0, 5);
    let db_acc = cluster_acc(&db.iter().map(|&l| if l < 0 { 2usize } else { l as usize }).collect::<Vec<_>>(), &truth);
    println!("  选 ε = {:.2}:2 簇 {} 噪声,acc = {:.3}", best_eps.0, best_eps.2, db_acc);

    // ---- 4. 谱聚类 ----
    let sp = spectral_cluster(&xs, 2, 10, 0.15, &mut rng);
    println!("\n[谱聚类] kNN = 10,σ = 0.15:acc = {:.3}", cluster_acc(&sp, &truth));

    // ---- 5. 三张结果图 ----
    // k 均值失败图
    let mut c = Canvas::new(560.0, 420.0, -2.2, 2.8, -1.6, 1.8);
    c.axes("x1", "x2");
    for (k, color) in [(0usize, PALETTE[1]), (1, PALETTE[3])] {
        let pts: Vec<(f64, f64)> = xs.iter().zip(km.iter()).filter(|(_, &a)| a == k).map(|(x, _)| *x).collect();
        c.dots(&pts, color, 3.0);
    }
    c.legend(&[("k 均值簇 0", PALETTE[1]), ("k 均值簇 1", PALETTE[3])]);
    let p1 = format!("{out_dir}/kmeans-moons.svg");
    c.save(&p1);

    // DBSCAN 结果图(噪声黑色)
    let mut c = Canvas::new(560.0, 420.0, -2.2, 2.8, -1.6, 1.8);
    c.axes("x1", "x2");
    for (k, color) in [(0i32, PALETTE[1]), (1, PALETTE[3])] {
        let pts: Vec<(f64, f64)> = xs.iter().zip(db.iter()).filter(|(_, &l)| l == k).map(|(x, _)| *x).collect();
        c.dots(&pts, color, 3.0);
    }
    let noise: Vec<(f64, f64)> = xs.iter().zip(db.iter()).filter(|(_, &l)| l == -1).map(|(x, _)| *x).collect();
    c.dots(&noise, "#16161d", 2.5);
    c.legend(&[("簇 0", PALETTE[1]), ("簇 1", PALETTE[3]), ("噪声", "#16161d")]);
    let p2 = format!("{out_dir}/dbscan.svg");
    c.save(&p2);

    // 谱聚类结果图
    let mut c = Canvas::new(560.0, 420.0, -2.2, 2.8, -1.6, 1.8);
    c.axes("x1", "x2");
    for (k, color) in [(0usize, PALETTE[1]), (1, PALETTE[3])] {
        let pts: Vec<(f64, f64)> = xs.iter().zip(sp.iter()).filter(|(_, &a)| a == k).map(|(x, _)| *x).collect();
        c.dots(&pts, color, 3.0);
    }
    c.legend(&[("谱簇 0", PALETTE[1]), ("谱簇 1", PALETTE[3])]);
    let p3 = format!("{out_dir}/spectral.svg");
    c.save(&p3);

    println!("图已写入:{p1}、{p2}、{p3}");
}

/// 聚类准确率:簇标签可任意置换,取与真标签最大重合(k=2 时直接两种对拍)。
fn cluster_acc(assign: &[usize], truth: &[usize]) -> f64 {
    let direct = assign.iter().zip(truth.iter()).filter(|(a, t)| a == t).count();
    let flipped = assign.len() - direct;
    direct.max(flipped) as f64 / assign.len() as f64
}

// ===================== 迷你 SVG 绘图器(与系列前篇一致) =====================

实现要点:

  • dbscan 是 BFS 的变体:核心点的 ε\varepsilon 邻域整体入队,弹出时若仍是核心点则继续扩展——labels[q] == -1 || -2 的判断收留边界点、跳过已入队的。
  • 谱聚类的 matvec 闭包把 M=D−1/2WD−1/2M = D^{-1/2} W D^{-1/2} 作用到向量上,不建 n×nn \times n 矩阵——邻接表稀疏存储,一次乘法 O(n⋅k)O(n \cdot k)。
  • power_iteration 是系列第一个迭代式特征求解器:反复做 v←Mv/∥Mv∥v \leftarrow Mv / \|Mv\| 收敛到主特征向量;取第二个时每次迭代先做与 v1v_1 的正交化(deflation),把已找到的”劈法”从算子里剥掉。2×2 用闭式解,n×nn \times n 用幂迭代——工具随问题尺寸升级。
// 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 升序传入。
    #[allow(dead_code)] // 本篇未用,保持与系列其他篇的绘图器逐字一致
    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 dbscan_splits_two_blobs() {
        let mut rng = XorShift::new(7);
        let mut xs = Vec::new();
        for _ in 0..30 {
            xs.push((rng.next_gauss(), rng.next_gauss()));
        }
        for _ in 0..30 {
            xs.push((10.0 + rng.next_gauss(), 10.0 + rng.next_gauss()));
        }
        let lb = dbscan(&xs, 1.5, 4);
        let clusters = lb.iter().copied().filter(|&l| l >= 0).max().unwrap() + 1;
        assert_eq!(clusters, 2);
        assert!(lb.iter().filter(|&&l| l == -1).count() == 0);
    }

    #[test]
    fn dbscan_flags_pure_noise() {
        let mut rng = XorShift::new(9);
        let xs: Vec<(f64, f64)> = (0..50).map(|_| (100.0 * rng.next_f64(), 100.0 * rng.next_f64())).collect();
        let lb = dbscan(&xs, 0.5, 4);
        assert!(lb.iter().all(|&l| l == -1));
    }

    #[test]
    fn power_iteration_recovers_dominant_eigenvector() {
        // 对角阵 diag(5, 1):主特征值 5,特征向量 e0
        let matvec = |v: &[f64]| vec![5.0 * v[0], 1.0 * v[1]];
        let (l, v) = power_iteration(2, &matvec, &[], &mut XorShift::new(3), 100);
        assert!((l - 5.0).abs() < 1e-6);
        assert!(v[0].abs() > 0.999);
    }

    #[test]
    fn spectral_splits_moons() {
        let mut rng = XorShift::new(42);
        let (xs, truth) = make_moons(&mut rng, 100, 0.05);
        let sp = spectral_cluster(&xs, 2, 10, 0.12, &mut XorShift::new(5));
        assert!(cluster_acc(&sp, &truth) > 0.95, "谱聚类应分开月牙: {}", cluster_acc(&sp, &truth));
    }
}

Rust 语法角:从函数指针到 trait 对象——dyn Fn 传算子

13 篇的核函数用裸函数指针(fn(...)),本篇的 power_iteration 升级成了 trait 对象:参数类型 matvec: &dyn Fn(&[f64]) -> Vec<f64>。区别在携带状态:裸函数指针只是地址,没有”捕获环境”的能力;dyn Fn 是”任何实现了 Fn trait 的东西”的胖指针——闭包、函数指针都行,闭包还能捕获外围变量(matvec 闭包捕获了 adj 和 sqrt_d)。代价是一次间接跳转。选择规则:无状态用 fn,有状态用 &dyn Fn 或泛型 impl Fn。见《Rust 程序设计语言》ch17-02(trait 对象)。

运行结果

cargo test(4 个用例:DBSCAN 分两 blob、纯噪声全标记、幂迭代主特征值精度、谱聚类分开月牙)全部通过后,cargo run:

数据:200 个点的交错月牙(σ = 0.06)

[对照] k 均值(球形假设):调整兰德后 acc = 0.835

[DBSCAN]  MinPts = 5,ε 扫描:
      ε      簇数   噪声点
  0.10     12      29
  0.15      4       5
  0.20      2       0
  0.25      2       0
  0.30      2       0
  选 ε = 0.20:2 簇 0 噪声,acc = 1.000

[谱聚类] kNN = 10,σ = 0.15:acc = 1.000
图已写入:../../../frontend/public/images/series/rust-ml-16-density-spectral/kmeans-moons.svg、../../../frontend/public/images/series/rust-ml-16-density-spectral/dbscan.svg、../../../frontend/public/images/series/rust-ml-16-density-spectral/spectral.svg

三张图:

k 均值在月牙上的失败:横切两弧

DBSCAN:密度连通还原月牙

谱聚类:图切分还原月牙

怎么读这些数字和图

  • k 均值对照组 0.835 分:球形簇假设逼着它横切两弧——图里能看到左上和右下被生硬地切成两半。这就是 15 篇结论的延伸:蛋形假设搞不定非凸结构。
  • ε 扫描是 DBSCAN 的性格写照:ε=0.10 时碎成 12 簇(噪声 29)——邻域太小,弧被切成段;ε=0.20 起稳定 2 簇 0 噪声;acc 1.000。ε 的唯一性区间很宽(0.2~0.3),但数据密度不均时这个窗口会消失(清单缺点)。
  • DBSCAN 与谱聚类殊途同归:两图都干净地分开月牙——前者靠”弧内部点密、弧之间桥窄”,后者靠”图上两弧之间的边权总和最小”。同一结构,两种数学目光。
  • 选型速查(清单 4.7 落地版):
数据特点推荐算法
球形簇、大样本、已知簇数k 均值
需要概率 / 软归属GMM
任意形状、有噪声、密度均匀DBSCAN / OPTICS
任意形状、中小样本、要谱系层次聚类 / 谱聚类
图结构 / 非流形结构谱聚类

优缺点与适用场景

(抄清单 4.5–4.6 原文)

  • DBSCAN:自动发现任意形状簇、自动识别噪声、无需预设簇数;但对 ε\varepsilon 和 MinPts 敏感、密度不均匀时效果差、高维失效。扩展:OPTICS(用可达距离排序解决全局 ε\varepsilon 敏感)、HDBSCAN(层次 + 密度自适应)。
  • 谱聚类:能发现任意形状簇(月牙、环形等非凸结构)、有坚实图论基础;但构图和 k 近邻参数敏感、相似度图计算 O(n2)O(n^2)、同样需要预设簇数。
  • 适用场景:DBSCAN——异常检测、空间数据;谱聚类——图像分割、社交网络社区发现、形状不规则的数据。

小结

聚类模块收官。四种算法给出四种”相似”的定义:球形距离(k 均值)、概率密度(GMM)、密度连通(DBSCAN)、图切分(谱聚类)——选算法就是选相似性。无监督的两大主题(降维、聚类)至此走完,系列只剩终篇:集成学习——把前面所有弱学习器装进一个投票箱,看”三个臭皮匠”如何炼成工业界的默认王者。