用 Rust 从头实现机器学习算法·分类①:逻辑回归与交叉熵

88 分钟阅读 rust-ml-from-scratch · 9
Rust机器学习

用 Rust 从头实现机器学习算法·分类①:逻辑回归与交叉熵

从本篇进入分类模块:预测离散类别标签。打头阵的是工业界最常用的分类基线——逻辑回归。它和我们刚告别的线性回归共享同一副骨架(线性打分 + 梯度下降),换掉的只有两处:输出经过 sigmoid 压缩成概率,损失从平方误差换成交叉熵。

核心思想

线性回归输出 z=w⊤x+bz = w^{\top}x + b 可以任意大或小,但”属于正类的概率”必须落在 (0,1)(0,1)。sigmoid 函数完成这个变换:

σ(z)=11+e−z\sigma(z) = \frac{1}{1 + e^{-z}}

它把整条实数轴单调压缩进 (0,1)(0,1):z=0z=0 时恰为 0.5(决策边界),z→+∞z \to +\infty 趋近 1,z→−∞z \to -\infty 趋近 0。清单指出逻辑回归本质是广义线性模型(GLM,清单 1.3)的 flagship:链接函数 g(⋅)g(\cdot) 让 g(E[y])=w⊤x+bg(E[y]) = w^{\top}x + b 保持线性——这里 gg 就是 logit,ln⁡p1−p=w⊤x+b\ln\frac{p}{1-p} = w^{\top}x + b。GLM 家族的思路一句话:响应变量不管多怪(分类、计数、比例),套一个合适的链接函数,线性模型框架就能接着用。

逻辑回归的输出天然是概率而非硬判决:加 0.5 阈值得到分类器,直接用概率则是风险评分——信贷审批、医学筛查要的正是这种”带置信度的答案”。

数学:交叉熵的出身与梯度

为什么不沿用平方误差?对分类问题,平方误差在 ∣z∣|z| 很大时梯度趋零,模型”自信地错了”就再也学不动。正确的损失要从极大似然推导:nn 个独立样本的似然是 ∏piyi(1−pi)1−yi\prod p_i^{y_i}(1-p_i)^{1-y_i},取负对数再平均:

L(w)=−1n∑i=1n[yiln⁡pi+(1−yi)ln⁡(1−pi)]L(w) = -\frac{1}{n}\sum_{i=1}^{n}\big[ y_i \ln p_i + (1-y_i)\ln(1-p_i) \big]

这就是交叉熵。对 ww 求导(链式法则穿过 sigmoid,有幸出现化简):

∇wL=1nΦ⊤(p−y)\nabla_w L = \frac{1}{n}\Phi^{\top}(p - y)

与线性回归的梯度 Φ⊤(y^−y)/n\Phi^{\top}(\hat{y}-y)/n 同构——残差从 (y^−y)(\hat{y}-y) 换成 (p−y)(p-y)。训练循环因此一行不用改。

Rust 实现

数据是两个有重叠的高斯 blob(类 0 中心 (1,1)(1,1)、类 1 中心 (3,3)(3,3),σ=1.4\sigma=1.4),训练/测试各 80 个点——重叠让”概率走廊”有真实的教学价值。

// src/main.rs(段一:Matrix、sigmoid/交叉熵/梯度与主程序)
// 分类①:逻辑回归与交叉熵
// 单文件、仅标准库。绘图器与系列前篇内联的是同一份实现。

// ===================== Matrix(最小实现:矩阵乘与转置) =====================

#[derive(Debug, Clone)]
struct Matrix {
    data: Vec<f64>,
    rows: usize,
    cols: usize,
}

impl 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()
    }
}

// ===================== 逻辑回归 =====================

/// 数值稳定的 sigmoid:σ(z) = 1 / (1 + e^(−z))。
fn sigmoid(z: f64) -> f64 {
    if z >= 0.0 {
        1.0 / (1.0 + (-z).exp())
    } else {
        let e = z.exp();
        e / (1.0 + e)
    }
}

/// 平均交叉熵:−(1/n) Σ [y ln p + (1−y) ln(1−p)]。
fn cross_entropy(ps: &[f64], ys: &[f64]) -> f64 {
    let n = ys.len();
    let eps = 1e-12; // 防 ln(0)
    let sum: f64 = ps
        .iter()
        .zip(ys.iter())
        .map(|(p, y)| y * (p + eps).ln() + (1.0 - y) * (1.0 - p + eps).ln())
        .sum();
    -sum / n as f64
}

/// 梯度:∇ = Φᵀ(p − y)/n。与线性回归同构,只是残差换成 (p − y)。
fn gradient(phi: &Matrix, ps: &[f64], ys: &[f64]) -> Vec<f64> {
    let (n, _) = phi.shape();
    let e: Vec<f64> = ps.iter().zip(ys.iter()).map(|(p, y)| p - y).collect();
    let g = phi.transpose().matmul(&Matrix::from_vec(e, n, 1));
    g.data.iter().map(|v| v / n as f64).collect()
}

/// 给定 w(含截距为最后一维),算全部样本的预测概率。
fn probabilities(phi: &Matrix, w: &[f64]) -> Vec<f64> {
    let (n, cols) = phi.shape();
    (0..n)
        .map(|i| {
            let mut z = 0.0;
            for j in 0..cols {
                z += phi.get(i, j) * w[j];
            }
            sigmoid(z)
        })
        .collect()
}

/// 准确率:预测概率 ≥ 0.5 判为 1。
fn accuracy(ps: &[f64], ys: &[f64]) -> f64 {
    let correct = ps.iter().zip(ys.iter()).filter(|(p, y)| (**p >= 0.5) == (**y > 0.5)).count();
    correct as f64 / ys.len() as f64
}

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

    // ---- 1. 数据:两个高斯 blob,类 0 中心 (1,1),类 1 中心 (3,3),σ = 0.6 ----
    let mut rng = XorShift::new(42);
    let n_per = 40usize;
    let blob = |rng: &mut XorShift, cx: f64, cy: f64, n: usize| -> (Vec<f64>, Vec<f64>, Vec<f64>) {
        let mut xs1 = Vec::with_capacity(n);
        let mut xs2 = Vec::with_capacity(n);
        let mut ys = Vec::with_capacity(n);
        for _ in 0..n {
            xs1.push(cx + 1.4 * rng.next_gauss());
            xs2.push(cy + 0.6 * rng.next_gauss());
            ys.push(if cx > 2.0 { 1.0 } else { 0.0 });
        }
        (xs1, xs2, ys)
    };
    let (mut x1tr, mut x2tr, mut ytr) = blob(&mut rng, 1.0, 1.0, n_per);
    let (a, b, c) = blob(&mut rng, 3.0, 3.0, n_per);
    x1tr.extend(a);
    x2tr.extend(b);
    ytr.extend(c);
    let (mut x1te, mut x2te, mut yte) = blob(&mut rng, 1.0, 1.0, n_per);
    let (a, b, c) = blob(&mut rng, 3.0, 3.0, n_per);
    x1te.extend(a);
    x2te.extend(b);
    yte.extend(c);
    let n = ytr.len();
    println!("train = {},test = {}(两类各半,σ = 1.4)", n, yte.len());

    // 设计矩阵:第 0、1 列是特征,最后一列恒 1(截距)
    let design = |x1: &[f64], x2: &[f64]| -> Matrix {
        Matrix::from_vec(
            x1.iter().zip(x2.iter()).flat_map(|(a, b)| [*a, *b, 1.0]).collect(),
            x1.len(),
            3,
        )
    };
    let phi_tr = design(&x1tr, &x2tr);
    let phi_te = design(&x1te, &x2te);

    // ---- 2. 梯度下降训练 ----
    let lr = 0.5;
    let epochs = 2000;
    let mut w = vec![0.0; 3];
    let mut loss_curve = Vec::new();
    for epoch in 1..=epochs {
        let ps = probabilities(&phi_tr, &w);
        let g = gradient(&phi_tr, &ps, &ytr);
        for j in 0..3 {
            w[j] -= lr * g[j];
        }
        if epoch % 100 == 0 || epoch == 1 {
            let ps = probabilities(&phi_tr, &w);
            loss_curve.push((epoch as f64, cross_entropy(&ps, &ytr)));
        }
    }
    let ps_tr = probabilities(&phi_tr, &w);
    let ps_te = probabilities(&phi_te, &w);
    println!(
        "最终:train CE = {:.4},acc = {:.3} | test acc = {:.3}",
        cross_entropy(&ps_tr, &ytr),
        accuracy(&ps_tr, &ytr),
        accuracy(&ps_te, &yte)
    );
    println!("w = [{:.3}, {:.3}],b = {:.3}", w[0], w[1], w[2]);

    // ---- 3. 抽样展示预测概率 ----
    println!("\n[抽样] x1     x2    | 真实 | P(1)  | 判定");
    for i in (0..n).step_by(16) {
        println!(
            "       {:.2}  {:.2} |  {:.0}   | {:.3} | {}",
            x1tr[i],
            x2tr[i],
            ytr[i],
            ps_tr[i],
            if ps_tr[i] >= 0.5 { "1" } else { "0" }
        );
    }

    // ---- 4. 图一:决策边界与概率走廊 ----
    // 边界 p=0.5 对应 w₁x₁ + w₂x₂ + b = 0;p=0.25/0.75 对应 z = ∓ln 3。
    let ln3 = 3.0f64.ln();
    let boundary_line = |x: f64, z: f64| -(w[0] * x + w[2] - z) / w[1];
    let grid: Vec<f64> = (0..=60).map(|k| k as f64 * 0.1 - 1.0).collect(); // x1 ∈ [-1, 5]
    let mut c = Canvas::new(560.0, 420.0, -1.0, 5.0, -1.0, 5.0);
    c.axes("x1", "x2");
    // 概率走廊:p=0.25 与 p=0.75 之间即「灰色地带」
    let lower: Vec<(f64, f64)> = grid.iter().map(|&x| (x, boundary_line(x, -ln3))).collect();
    let upper: Vec<(f64, f64)> = grid.iter().map(|&x| (x, boundary_line(x, ln3))).collect();
    c.band(&upper, &lower, "#2563eb");
    c.polyline(&grid.iter().map(|&x| (x, boundary_line(x, 0.0))).collect::<Vec<_>>(), PALETTE[0], 2.4);
    c.polyline(&lower, PALETTE[2], 1.2);
    c.polyline(&upper, PALETTE[2], 1.2);
    let class0: Vec<(f64, f64)> = x1tr.iter().zip(x2tr.iter()).zip(ytr.iter()).filter(|(_, y)| **y < 0.5).map(|((a, b), _)| (*a, *b)).collect();
    let class1: Vec<(f64, f64)> = x1tr.iter().zip(x2tr.iter()).zip(ytr.iter()).filter(|(_, y)| **y > 0.5).map(|((a, b), _)| (*a, *b)).collect();
    c.dots(&class0, PALETTE[1], 3.5);
    c.dots(&class1, PALETTE[3], 3.5);
    c.legend(&[("p = 0.5 边界", PALETTE[0]), ("p = 0.25/0.75", PALETTE[2]), ("class 0", PALETTE[1]), ("class 1", PALETTE[3])]);
    let p1 = format!("{out_dir}/decision-boundary.svg");
    c.save(&p1);

    // ---- 5. 图二:sigmoid 拟合——样本按 z 排列,曲线压实 0/1 ----
    let zs: Vec<(f64, f64, f64)> = (0..n)
        .map(|i| (w[0] * x1tr[i] + w[1] * x2tr[i] + w[2], ytr[i], ps_tr[i]))
        .collect();
    let mut c = Canvas::new(560.0, 320.0, -8.0, 8.0, -0.05, 1.05);
    c.axes("z = wᵀx + b", "概率");
    let sig: Vec<(f64, f64)> = (-80..=80).map(|k| { let z = k as f64 * 0.1; (z, sigmoid(z)) }).collect();
    c.polyline(&sig, PALETTE[0], 2.2);
    let neg: Vec<(f64, f64)> = zs.iter().filter(|(_, y, _)| *y < 0.5).map(|(z, y, _)| (*z, *y)).collect();
    let pos: Vec<(f64, f64)> = zs.iter().filter(|(_, y, _)| *y > 0.5).map(|(z, y, _)| (*z, *y)).collect();
    c.dots(&neg, PALETTE[1], 3.0);
    c.dots(&pos, PALETTE[3], 3.0);
    c.legend(&[("σ(z)", PALETTE[0]), ("class 0", PALETTE[1]), ("class 1", PALETTE[3])]);
    let p2 = format!("{out_dir}/sigmoid-fit.svg");
    c.save(&p2);

    // ---- 6. 图三:交叉熵收敛 ----
    let mut c = Canvas::new(560.0, 320.0, 0.0, 2000.0, 0.0, 0.0);
    c.axes("epoch", "交叉熵");
    c.polyline(&loss_curve, PALETTE[0], 2.0);
    let p3 = format!("{out_dir}/loss.svg");
    c.save(&p3);

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

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

(与系列前篇一致) =====================

/// 系列默认配色:朱橙 / 青绿 / 蓝 / 琥珀(取自站点设计令牌)。
const PALETTE: [&str; 4] = ["#d6491f", "#0f766e", "#2563eb", "#d97706"];

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 {
    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)
    }

    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)
            );
        }
    }

    /// 置信带:upper/lower 两条曲线围成的区域,半透明填充。
    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 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
        );
    }

    /// 图例:右上角依次画「色线 + 文字」。
    fn legend(&mut self, entries: &[(&str, &str)]) {
        let sample_w = 22.0;
        let line_h = 18.0;
        let x0 = self.w - self.pad_r - 78.0;
        let x_text = x0 + 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 _ = 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 失败");
    }
}

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)
    }
}

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
}

实现要点:

  • sigmoid 做了数值稳定处理:z≥0z \ge 0 与 z<0z < 0 两支各取不会溢出的形式,z=30z=30 也不会算出 inf。
  • cross_entropy 里给对数加 10−1210^{-12} 的 epsilon,防 ln(0)。
  • gradient 是 Φ⊤(p−y)/n\Phi^{\top}(p-y)/n 的直译——06 篇的 sub 都没有了,残差直接构造。
  • 决策边界可视化:p=0.5p=0.5 对应 z=0z=0,p=0.25/0.75p=0.25/0.75 对应 z=∓ln⁡3z=\mp\ln 3——三条平行线画出一个”灰色地带”,概率的渐变第一次有了几何形象。
// src/main.rs 段二:迷你 SVG 绘图器(与系列前篇同一份实现)
use std::fmt::Write as _;
// src/main.rs 末尾:单元测试(cargo test 运行)
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sigmoid_basic_properties() {
        assert!((sigmoid(0.0) - 0.5).abs() < 1e-12);
        let x = 2.5;
        assert!((sigmoid(-x) - (1.0 - sigmoid(x))).abs() < 1e-12); // 对称性
        assert!(sigmoid(30.0) > 0.9999 && sigmoid(-30.0) < 0.0001); // 饱和区不溢出
    }

    #[test]
    fn cross_entropy_extremes() {
        let y = vec![1.0, 0.0];
        let perfect = cross_entropy(&[0.999999, 1e-6], &y);
        let wrong = cross_entropy(&[1e-6, 0.999999], &y);
        assert!(perfect < 1e-4, "完美预测损失应近 0: {perfect}");
        assert!(wrong > 10.0, "全猜反损失应很大: {wrong}");
    }

    #[test]
    fn gradient_matches_finite_difference() {
        // 数值梯度检验:解析梯度 vs 中心差分
        let phi = Matrix::from_vec(vec![1.0, 2.0, 1.0, 3.0, 1.0, 1.0, 0.5, 1.0], 2, 4);
        let ys = vec![1.0, 0.0];
        let w = vec![0.3, -0.2, 0.1, 0.05];
        let h = 1e-6;
        let loss = |w: &Vec<f64>| cross_entropy(&probabilities(&phi, w), &ys);
        let ps = probabilities(&phi, &w);
        let g = gradient(&phi, &ps, &ys);
        for j in 0..4 {
            let mut wp = w.clone();
            wp[j] += h;
            let mut wm = w.clone();
            wm[j] -= h;
            let num = (loss(&wp) - loss(&wm)) / (2.0 * h);
            assert!((g[j] - num).abs() < 1e-5, "维度 {j}: 解析 {} vs 数值 {}", g[j], num);
        }
    }

    #[test]
    fn accuracy_counts_threshold() {
        let ps = vec![0.4, 0.6, 0.51, 0.49];
        let ys = vec![0.0, 1.0, 1.0, 0.0];
        assert!((accuracy(&ps, &ys) - 1.0).abs() < 1e-12);
    }
}

Rust 语法角:fmt::Write、trait 与 as _

绘图器第一行的 use std::fmt::Write as _; 值得拆解。write! 宏能把文本写进任何”可写”目标,但前提是目标类型实现了 Write trait——而标准库里有两个 Write:std::fmt::Write(写进 String)和 std::io::Write(写进文件/终端)。同名冲突时 as _ 把导入的名字抹成匿名:trait 的方法仍可用于类型推断,但不会和别的同名项打架。trait 是 Rust 抽象的核心机制(“只要实现了这个接口,就拥有这套行为”),值得系统学习——《Rust 程序设计语言》ch10-02(trait)。

运行结果

cargo test(4 个用例:sigmoid 对称性与饱和区、交叉熵的两个极端、梯度与中心差分数值对拍、阈值判定)全部通过后,cargo run:

train = 80,test = 80(两类各半,σ = 1.4)
最终:train CE = 0.0905,acc = 0.975 | test acc = 0.988
w = [1.715, 4.306],b = -11.029

[抽样] x1     x2    | 真实 | P(1)  | 判定
       1.00  1.41 |  0   | 0.038 | 0
       0.82  0.58 |  0   | 0.001 | 0
       0.99  1.90 |  0   | 0.243 | 0
       2.12  2.72 |  1   | 0.987 | 1
       1.76  3.09 |  1   | 0.995 | 1
图已写入:../../../frontend/public/images/series/rust-ml-09-logistic/decision-boundary.svg、../../../frontend/public/images/series/rust-ml-09-logistic/sigmoid-fit.svg、../../../frontend/public/images/series/rust-ml-09-logistic/loss.svg

三张图:

决策边界与概率走廊

sigmoid 拟合:样本按 z 排列,概率曲线压实 0/1

交叉熵收敛曲线

怎么读这些数字和图

  • CE = 0.0905,train/test 准确率 0.975/0.988:两类重叠 σ=1.4\sigma=1.4 时仍有约 2.5% 的训练点分错——这是数据的真实重叠,不是模型偷懒。测试准确率高于训练是随机波动,不是玄学。
  • 抽样表看概率的语义:P(1)=0.243 的样本虽被正确判为 0,但模型承认”它离边界不远”;P=0.001 与 0.995 的样本则是模型的”舒适区”。把阈值从 0.5 挪到 0.2,就是另一个业务(宁可错杀)——概率输出给了阈值调参的余地,这是”输出类别”的模型给不了的。
  • 决策边界图的灰色走廊:两条蓝线之间是 p∈(0.25,0.75)p \in (0.25, 0.75) 的过渡带,宽度由 w 的尺度决定;训练点零星落入走廊甚至越界,正是那 2.5% 错误的几何位置。
  • sigmoid 拟合图:横轴是 z=w⊤x+bz = w^{\top}x+b,两条色带铺在 0/1 两排,曲线从它们中间压实穿过——逻辑回归的全部工作,就是把这条 S 形曲线摆到”让正确一侧概率尽量大”的位置。
  • loss 曲线前 200 轮陡降、之后平缓:CE 的梯度在分错且自信时最大(对照平方误差的”自信地错就躺平”),越接近答案步幅自然越小。

优缺点与适用场景

(抄清单 1.5 原文)

  • 优点:输出概率、系数可解释(odds ratio)、训练快、不易过拟合。
  • 缺点:只能表达线性决策边界(不加特征变换时)。
  • 适用场景:信贷评分、医学风险预测、点击率预估——需要概率输出的工业界首选基线。

小结

分类模块的第一块基石:sigmoid 负责把线性分数变成概率,交叉熵负责”自信地错就要狠狠罚”。决策边界仍是直线——特征变换或核技巧可以把它掰弯,但那是 SVM 篇的故事。下一篇分类②:k 近邻——不训练、不假设、纯靠”近朱者赤”投票的最懒分类器。