用 Rust 从头实现机器学习算法·分类①:逻辑回归与交叉熵
用 Rust 从头实现机器学习算法·分类①:逻辑回归与交叉熵
从本篇进入分类模块:预测离散类别标签。打头阵的是工业界最常用的分类基线——逻辑回归。它和我们刚告别的线性回归共享同一副骨架(线性打分 + 梯度下降),换掉的只有两处:输出经过 sigmoid 压缩成概率,损失从平方误差换成交叉熵。
核心思想
线性回归输出 可以任意大或小,但”属于正类的概率”必须落在 。sigmoid 函数完成这个变换:
它把整条实数轴单调压缩进 : 时恰为 0.5(决策边界), 趋近 1, 趋近 0。清单指出逻辑回归本质是广义线性模型(GLM,清单 1.3)的 flagship:链接函数 让 保持线性——这里 就是 logit,。GLM 家族的思路一句话:响应变量不管多怪(分类、计数、比例),套一个合适的链接函数,线性模型框架就能接着用。
逻辑回归的输出天然是概率而非硬判决:加 0.5 阈值得到分类器,直接用概率则是风险评分——信贷审批、医学筛查要的正是这种”带置信度的答案”。
数学:交叉熵的出身与梯度
为什么不沿用平方误差?对分类问题,平方误差在 很大时梯度趋零,模型”自信地错了”就再也学不动。正确的损失要从极大似然推导: 个独立样本的似然是 ,取负对数再平均:
这就是交叉熵。对 求导(链式法则穿过 sigmoid,有幸出现化简):
与线性回归的梯度 同构——残差从 换成 。训练循环因此一行不用改。
Rust 实现
数据是两个有重叠的高斯 blob(类 0 中心 、类 1 中心 ,),训练/测试各 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做了数值稳定处理: 与 两支各取不会溢出的形式, 也不会算出inf。cross_entropy里给对数加 的 epsilon,防ln(0)。gradient是 的直译——06 篇的sub都没有了,残差直接构造。- 决策边界可视化: 对应 , 对应 ——三条平行线画出一个”灰色地带”,概率的渐变第一次有了几何形象。
// 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
三张图:
怎么读这些数字和图
- CE = 0.0905,train/test 准确率 0.975/0.988:两类重叠 时仍有约 2.5% 的训练点分错——这是数据的真实重叠,不是模型偷懒。测试准确率高于训练是随机波动,不是玄学。
- 抽样表看概率的语义:P(1)=0.243 的样本虽被正确判为 0,但模型承认”它离边界不远”;P=0.001 与 0.995 的样本则是模型的”舒适区”。把阈值从 0.5 挪到 0.2,就是另一个业务(宁可错杀)——概率输出给了阈值调参的余地,这是”输出类别”的模型给不了的。
- 决策边界图的灰色走廊:两条蓝线之间是 的过渡带,宽度由 w 的尺度决定;训练点零星落入走廊甚至越界,正是那 2.5% 错误的几何位置。
- sigmoid 拟合图:横轴是 ,两条色带铺在 0/1 两排,曲线从它们中间压实穿过——逻辑回归的全部工作,就是把这条 S 形曲线摆到”让正确一侧概率尽量大”的位置。
- loss 曲线前 200 轮陡降、之后平缓:CE 的梯度在分错且自信时最大(对照平方误差的”自信地错就躺平”),越接近答案步幅自然越小。
优缺点与适用场景
(抄清单 1.5 原文)
- 优点:输出概率、系数可解释(odds ratio)、训练快、不易过拟合。
- 缺点:只能表达线性决策边界(不加特征变换时)。
- 适用场景:信贷评分、医学风险预测、点击率预估——需要概率输出的工业界首选基线。
小结
分类模块的第一块基石:sigmoid 负责把线性分数变成概率,交叉熵负责”自信地错就要狠狠罚”。决策边界仍是直线——特征变换或核技巧可以把它掰弯,但那是 SVM 篇的故事。下一篇分类②:k 近邻——不训练、不假设、纯靠”近朱者赤”投票的最懒分类器。