用 Rust 从头实现机器学习算法·回归③:多项式回归与过拟合

85 分钟阅读 rust-ml-from-scratch · 5
Rust机器学习

用 Rust 从头实现机器学习算法·回归③:多项式回归与过拟合

前两篇把梯度下降打磨成了可靠的工具。本篇用它打开机器学习里最重要的认知关卡之一:模型复杂度与泛化能力的博弈。主角是多项式回归——线性回归家族里最便宜的非线性方案,也是教科书里过拟合的”惯犯”。

核心思想

线性回归的”线性”指的是对参数线性,不是对特征线性。只要把一维输入 xx 升维成

ϕ(x)=(1,x,x2,…,xM)\phi(x) = (1, x, x^2, \dots, x^M)

那么 y=w0+w1x+⋯+wMxMy = w_0 + w_1 x + \cdots + w_M x^M 对 ww 仍然是线性的——损失、梯度、训练循环全部沿用 03、04 篇的结论,一个公式都不用改。变的只有特征工程:升维拟合曲线。

这对应清单里对多项式回归的定义:它本质上是「特征工程 + 线性回归」,仍可用 OLS 框架求解。

数学:设计矩阵与两个隐患

把 nn 个样本升维后 stacking 起来,得到设计矩阵 Φ\Phi(形状 n×(M+1)n \times (M+1)):

Φij=xi j,j=0,1,…,M\Phi_{ij} = x_i^{\,j}, \qquad j = 0, 1, \dots, M

第 0 列恒为 1(截距项),第 jj 列是 xx 的 jj 次幂。损失与梯度照搬多元结论:

L(w)=12n∥Φw−y∥2,∇wL=1nΦ⊤(Φw−y)L(\boldsymbol{w}) = \frac{1}{2n} \|\Phi \boldsymbol{w} - \boldsymbol{y}\|^2, \qquad \nabla_{\boldsymbol{w}} L = \frac{1}{n} \Phi^{\top} (\Phi \boldsymbol{w} - \boldsymbol{y})

但升维带来两个一元情形没有的新问题:

  1. 量纲爆炸:x∈[0,5]x \in [0,5] 时 x9x^9 可以到 59≈2005^9 \approx 200 万,梯度下降的”山谷”比 04 篇的房价例子还要狭长——04 的标准化技巧在这里从”可选”变成”必须”。
  2. 高阶列高度相关:x8x^8 与 x9x^9 几乎成比例,Φ⊤Φ\Phi^{\top}\Phi 接近奇异——这正是清单里说的”高阶项之间高度相关,引发数值不稳定”,也是下一篇正则化要根治的病灶。

Rust 实现

造一条有起伏的三次曲线当真值,加均匀噪声,用 15 个训练点拟合、30 个独立验证点打分。实验一对比 M = 1/3/9 三条拟合曲线,实验二扫描 M = 0~9 画出训练/验证误差曲线。

// src/main.rs(段一:Matrix、XorShift、特征、训练器与两个实验;段二为绘图器)
// 回归③:多项式回归与过拟合
// 单文件、仅标准库。绘图器与 01~04 篇内联的是同一份实现。

// ===================== 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]
    }

    /// 矩阵乘法:(n×m) · (m×p) → (n×p)。
    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
    }

    /// 转置:n×m → m×n。
    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
    }

    /// 逐元素相减:self − other(形状必须一致)。
    fn sub(&self, other: &Matrix) -> Matrix {
        assert_eq!(self.shape(), other.shape(), "形状不一致,无法相减");
        let mut out = Matrix::zeros(self.rows, self.cols);
        for i in 0..self.data.len() {
            out.data[i] = self.data[i] - other.data[i];
        }
        out
    }
}

// ===================== xorshift64 伪随机数(沿用 03 篇) =====================

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 truth(x: f64) -> f64 {
    0.15 * x * x * x - x * x + 2.0 * x + 3.0
}

/// 把一列原始特征 x 升维成设计矩阵的 n×(m+1) 矩阵:
/// 第 j 列是 x 的 j 次幂,第 0 列恒为 1(截距项)。
fn design(xs: &[f64], m: usize) -> Matrix {
    let n = xs.len();
    let mut data = vec![0.0; n * (m + 1)];
    for (i, x) in xs.iter().enumerate() {
        let mut v = 1.0;
        for j in 0..=m {
            data[i * (m + 1) + j] = v;
            v *= x;
        }
    }
    Matrix::from_vec(data, n, m + 1)
}

/// 按训练集每一列的 μ/σ 标准化(第 0 列截距项除外)。
/// 返回 (标准化后的矩阵, mu, sigma),供训练与预测共用。
fn standardize(train: &Matrix) -> (Matrix, Vec<f64>, Vec<f64>) {
    let (n, cols) = train.shape();
    let mut mu = vec![0.0; cols];
    let mut sigma = vec![0.0; cols];
    for j in 0..cols {
        if j == 0 {
            continue; // 截距列不动
        }
        let col: Vec<f64> = (0..n).map(|i| train.get(i, j)).collect();
        mu[j] = col.iter().sum::<f64>() / n as f64;
        let var = col.iter().map(|v| (v - mu[j]).powi(2)).sum::<f64>() / n as f64;
        sigma[j] = var.sqrt();
        if sigma[j] < 1e-12 {
            sigma[j] = 1.0; // 常数列防除零
        }
    }
    let mut out = train.clone();
    for j in 1..cols {
        for i in 0..n {
            out.data[i * cols + j] = (train.get(i, j) - mu[j]) / sigma[j];
        }
    }
    (out, mu, sigma)
}

/// 用标准化参数把一行原始特征 [1, x, x², ..., x^m] 变换后,与 w 做点积得到预测。
fn predict(x: f64, w: &[f64], mu: &[f64], sigma: &[f64]) -> f64 {
    let mut v = 1.0; // x^0
    let mut yhat = w[0]; // 截距项不参与标准化
    for j in 1..w.len() {
        v *= x;
        yhat += w[j] * (v - mu[j]) / sigma[j];
    }
    yhat
}

/// 多元梯度下降:w ← w − η · Xᵀ(Xw − y)/n。返回训练后的权重(一维,长度 = 列数)。
fn fit(x: &Matrix, ys: &[f64], lr: f64, epochs: usize) -> Vec<f64> {
    let (n, cols) = x.shape();
    let mut w = Matrix::from_vec(vec![0.0; cols], cols, 1);
    let y = Matrix::from_vec(ys.to_vec(), n, 1);
    for _ in 0..epochs {
        let e = x.matmul(&w).sub(&y); // 残差 Xw − y,n×1
        let grad = x.transpose().matmul(&e); // Xᵀe,cols×1
        for j in 0..cols {
            w.data[j] -= lr * grad.data[j] / n as f64;
        }
    }
    w.data
}

/// 均方误差。
fn mse(yhats: &[f64], ys: &[f64]) -> f64 {
    let n = ys.len();
    let se: f64 = yhats.iter().zip(ys.iter()).map(|(a, b)| (a - b).powi(2)).sum();
    se / n as f64
}

/// 打印一行实验结果:宏在「Rust 语法角」一节展开讲。
macro_rules! mse_row {
    ($m:expr, $tr:expr, $va:expr) => {
        println!("M = {} | train MSE = {:>10.4} | val MSE = {:>10.4}", $m, $tr, $va);
    };
}

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

    // ---- 1. 造数据:15 个训练点 + 30 个验证点,真值三次曲线 + 均匀噪声 ----
    let mut rng = XorShift::new(42);
    let n_train = 15usize;
    let n_val = 30usize;
    let sample = |rng: &mut XorShift, n: usize| -> (Vec<f64>, Vec<f64>) {
        let mut xs = Vec::with_capacity(n);
        let mut ys = Vec::with_capacity(n);
        for _ in 0..n {
            let x = rng.next_f64() * 5.0; // x ∈ [0, 5)
            let eps = (rng.next_f64() - 0.5) * 3.0; // 噪声 ε ∈ [-1.5, 1.5)
            xs.push(x);
            ys.push(truth(x) + eps);
        }
        (xs, ys)
    };
    let (xtr, ytr) = sample(&mut rng, n_train);
    let (xva, yva) = sample(&mut rng, n_val);
    println!("train = {} 个点,val = {} 个点", n_train, n_val);

    let lr = 0.05;
    let epochs = 4000;

    // ---- 2. 实验一:M = 1 / 3 / 9 的拟合曲线对比 ----
    let showcase = [1usize, 3, 9];
    let mut curves: Vec<(usize, Vec<(f64, f64)>, f64, f64)> = Vec::new();
    for &m in &showcase {
        let xmat = design(&xtr, m);
        let (xstd, mu, sigma) = standardize(&xmat);
        let w = fit(&xstd, &ytr, lr, epochs);
        let tr = mse(&(0..n_train).map(|i| predict(xtr[i], &w, &mu, &sigma)).collect::<Vec<_>>(), &ytr);
        let va = mse(&(0..n_val).map(|i| predict(xva[i], &w, &mu, &sigma)).collect::<Vec<_>>(), &yva);
        mse_row!(m, tr, va);
        let grid: Vec<(f64, f64)> = (0..=100).map(|k| {
            let x = k as f64 * 0.05;
            (x, predict(x, &w, &mu, &sigma))
        }).collect();
        curves.push((m, grid, tr, va));
    }

    // 训练散点 + 三条拟合曲线
    let ymin = ytr.iter().cloned().fold(f64::INFINITY, f64::min).min(0.0);
    let ymax = ytr.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + 2.0;
    let mut c = Canvas::new(560.0, 380.0, 0.0, 5.0, ymin, ymax);
    c.axes("x", "y");
    c.dots(&xtr.iter().copied().zip(ytr.iter().copied()).collect::<Vec<_>>(), "#16161d", 3.5);
    let mut labels: Vec<String> = vec!["train data".into()];
    for (i, (m, grid, _, _)) in curves.iter().enumerate() {
        c.polyline(grid, PALETTE[i], 2.2);
        labels.push(format!("M = {m}"));
    }
    let mut colors: Vec<&str> = vec!["#16161d"];
    colors.extend((0..curves.len()).map(|i| PALETTE[i]));
    let entries: Vec<(&str, &str)> = labels.iter().zip(colors.iter()).map(|(l, c)| (l.as_str(), *c)).collect();
    c.legend(&entries);
    let p1 = format!("{out_dir}/fit-compare.svg");
    c.save(&p1);

    // ---- 3. 实验二:M = 0..=9 的训练/验证误差 U 形曲线 ----
    let mut train_curve = Vec::new();
    let mut val_curve = Vec::new();
    let mut best = (0usize, f64::INFINITY);
    for m in 0..=9usize {
        let xmat = design(&xtr, m);
        let (xstd, mu, sigma) = standardize(&xmat);
        let w = fit(&xstd, &ytr, lr, epochs);
        let tr = mse(&(0..n_train).map(|i| predict(xtr[i], &w, &mu, &sigma)).collect::<Vec<_>>(), &ytr);
        let va = mse(&(0..n_val).map(|i| predict(xva[i], &w, &mu, &sigma)).collect::<Vec<_>>(), &yva);
        mse_row!(m, tr, va);
        train_curve.push((m as f64, tr));
        val_curve.push((m as f64, va));
        if va < best.1 {
            best = (m, va);
        }
    }
    println!("验证集最优阶数 M = {}(val MSE = {:.4})", best.0, best.1);

    let ymax = train_curve.iter().chain(val_curve.iter()).map(|p| p.1).fold(0.0f64, f64::max) * 1.1;
    let mut c = Canvas::new(560.0, 380.0, 0.0, 9.0, 0.0, ymax);
    c.axes("多项式阶数 M", "MSE");
    c.polyline(&train_curve, PALETTE[0], 2.0);
    c.polyline(&val_curve, PALETTE[1], 2.0);
    c.dots(&[(best.0 as f64, best.1)], PALETTE[2], 5.0); // 标注验证集最优阶数
    c.legend(&[("train", PALETTE[0]), ("val", PALETTE[1]), ("best val", PALETTE[2])]);
    let p2 = format!("{out_dir}/order-u-curve.svg");
    c.save(&p2);

    println!("图已写入:{p1} 与 {p2}");
}
// src/main.rs 段二:迷你 SVG 绘图器——与 01~04 篇内联的是同一份实现,本篇起新增 legend 图例。
use std::fmt::Write as _;

// ===================== 迷你 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)
            );
        }
    }

    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
}
// src/main.rs 末尾:单元测试(cargo test 运行)
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn design_matrix_powers_x() {
        // x = 2, M = 3 → [1, 2, 4, 8]
        let x = design(&[2.0], 3);
        assert_eq!(x.shape(), (1, 4));
        for (j, want) in [1.0, 2.0, 4.0, 8.0].iter().enumerate() {
            assert_eq!(x.get(0, j), *want);
        }
    }

    #[test]
    fn standardize_keeps_intercept_column() {
        let x = design(&[1.0, 2.0, 3.0], 2);
        let (std, _, _) = standardize(&x);
        for i in 0..3 {
            assert_eq!(std.get(i, 0), 1.0); // 截距列原样保留
        }
    }

    #[test]
    fn standardize_makes_unit_variance() {
        let x = design(&[1.0, 2.0, 3.0, 4.0, 5.0], 1);
        let (std, mu, sigma) = standardize(&x);
        // 第 1 列 x 本身:μ = 3,σ² = 2(总体方差),标准化后均值为 0
        assert!((mu[1] - 3.0).abs() < 1e-12);
        assert!((sigma[1] - 2.0f64.sqrt()).abs() < 1e-12);
        let mean: f64 = (0..5).map(|i| std.get(i, 1)).sum::<f64>() / 5.0;
        assert!(mean.abs() < 1e-12);
    }

    #[test]
    fn matmul_enforces_inner_dim() {
        let a = Matrix::zeros(2, 3);
        let b = Matrix::zeros(2, 3);
        let result = std::panic::catch_unwind(|| a.matmul(&b));
        assert!(result.is_err());
    }
}

实现要点:

  • design 是全文最核心的七行:逐样本逐幂次填充 Φij=xij\Phi_{ij} = x_i^j。v *= x 的递推比每次调 powi 少算重复乘法,也避免高次幂的浮点误差累积。
  • standardize 沿用 04 篇的纪律:μ/σ 只在训练集上估计(本例验证点独立同分布,但仍按规矩来),第 0 列截距项跳过;常数列的 σ 置 1 防除零。
  • fit 矩阵化后只剩三行:matmul 得预测、sub 得残差、transpose + matmul 得梯度——这就是 02 篇末尾”对整个数据集的一次预测只需一行矩阵乘法”的兑现。
  • predict 与训练共用同一组 (mu, sigma),保证训练与评估看到的是同一个特征空间。

Rust 语法角:vec! 与 macro_rules!

代码里的 mse_row! 是我们系列里第一次自定义宏。在 Python 里没有对应物:最接近的是装饰器,但装饰器包装的是运行期的函数对象;Rust 的宏是编译期的代码模板——mse_row!(m, tr, va) 在编译时按规则展开成那条 println!,运行时没有任何包装开销。系列里一直在用的 println!、format!、vec!、assert_eq! 其实全是宏:vec![0.0; cols] 展开后等价于反复 push 构造 Vec。想深入了解写法见《Rust 程序设计语言》第 19 章(ch19-06)。

运行结果

cargo test(4 个用例:设计矩阵的幂次、截距列不动、标准化后的均值归零、matmul 内维断言)全部通过后,cargo run 输出(种子固定,逐字可复现):

train = 15 个点,val = 30 个点
M = 1 | train MSE =     0.2648 | val MSE =     1.3610
M = 3 | train MSE =     0.2401 | val MSE =     1.1419
M = 9 | train MSE =     0.2324 | val MSE =     1.2712
M = 0 | train MSE =     0.2804 | val MSE =     1.4987
M = 1 | train MSE =     0.2648 | val MSE =     1.3610
M = 2 | train MSE =     0.2520 | val MSE =     1.2713
M = 3 | train MSE =     0.2401 | val MSE =     1.1419
M = 4 | train MSE =     0.2357 | val MSE =     1.0219
M = 5 | train MSE =     0.2355 | val MSE =     0.9559
M = 6 | train MSE =     0.2354 | val MSE =     0.9203
M = 7 | train MSE =     0.2348 | val MSE =     0.9099
M = 8 | train MSE =     0.2338 | val MSE =     0.9747
M = 9 | train MSE =     0.2324 | val MSE =     1.2712
验证集最优阶数 M = 7(val MSE = 0.9099)
图已写入:../../../frontend/public/images/series/rust-ml-05-polynomial/fit-compare.svg 与 ../../../frontend/public/images/series/rust-ml-05-polynomial/order-u-curve.svg

三张关键图:

M = 1 / 3 / 9 拟合曲线对比

训练/验证误差随阶数的 U 形曲线

怎么读这些数字和图

  • train MSE 单调下降,但降幅递减(0.28 → 0.23):阶数越高,训练集拟合越好,但每加一阶的”收益”越来越小。看 fit-compare.svg:M=9 的曲线扭动着穿过每一个训练点——它确实”记住”了数据。
  • val MSE 是 U 形:从 M=0 的 1.50 一路降到 M=7 的 0.91,然后 M=8、M=9 明显反弹(0.97、1.27)。训练集上的”越来越好”在没见过的新数据上失效了——这就是过拟合,第一次被定量地看见。
  • 一个诚实而有教益的细节:真值明明是三次曲线,验证集选出的最优却是 M=7 而不是 M=3。原因是 15 个训练点太少、噪声又不小(ε ∈ [-1.5, 1.5],方差 0.75),参数估计本身有方差;M=3~7 的 val MSE 相差其实不大(1.14 对 0.91),在这个精度上”哪个阶最好”并无定论。用有限数据选模型,选出来的答案本身也有噪声——这正是清单强调”必须配合正则化或交叉验证选择阶数”的原因,也是下一篇的主题。
  • 噪声地板:ε 的方差 0.75 是任何模型都无法突破的”地板”(噪声本质不可预测)。val MSE 谷底 0.91 已贴近地板,说明曲线里可学的规律基本被学到了;M=9 的 1.27 则是把训练集噪声也背了下来,离地板反而更远。

优缺点与适用场景

(抄清单 1.4 原文)

  • 优点:简单即可引入非线性。
  • 缺点:过拟合的典型教材案例——阶数过高时曲线在样本间剧烈震荡;高阶项之间高度相关,引发数值不稳定。
  • 关键认知:多项式回归是理解「模型复杂度 vs 泛化能力」的最佳入口,必须配合正则化或交叉验证选择阶数。

适用场景:需要快速给一维/低维数据加非线性基线的场合;更常作为理解偏差-方差权衡的教学标本,而非生产首选。

小结

升维没有引入任何新算法——design 构造矩阵之后,一切仍是 03/04 篇的梯度下降。真正的收获是认知层面的:train 永远欢迎更复杂的模型,val 才会说”不”;两条曲线的分歧点就是过拟合现形的地方。但”换个阶数重训一遍”毕竟是笨办法——能不能在一个模型里同时容纳”拟合数据”与”克制参数”?能,给损失函数加一项惩罚即可。下一篇回归④:正则化三兄弟——岭回归、套索与弹性网络。