用 Rust 从头实现机器学习算法·回归③:多项式回归与过拟合
用 Rust 从头实现机器学习算法·回归③:多项式回归与过拟合
前两篇把梯度下降打磨成了可靠的工具。本篇用它打开机器学习里最重要的认知关卡之一:模型复杂度与泛化能力的博弈。主角是多项式回归——线性回归家族里最便宜的非线性方案,也是教科书里过拟合的”惯犯”。
核心思想
线性回归的”线性”指的是对参数线性,不是对特征线性。只要把一维输入 升维成
那么 对 仍然是线性的——损失、梯度、训练循环全部沿用 03、04 篇的结论,一个公式都不用改。变的只有特征工程:升维拟合曲线。
这对应清单里对多项式回归的定义:它本质上是「特征工程 + 线性回归」,仍可用 OLS 框架求解。
数学:设计矩阵与两个隐患
把 个样本升维后 stacking 起来,得到设计矩阵 (形状 ):
第 0 列恒为 1(截距项),第 列是 的 次幂。损失与梯度照搬多元结论:
但升维带来两个一元情形没有的新问题:
- 量纲爆炸: 时 可以到 万,梯度下降的”山谷”比 04 篇的房价例子还要狭长——04 的标准化技巧在这里从”可选”变成”必须”。
- 高阶列高度相关: 与 几乎成比例, 接近奇异——这正是清单里说的”高阶项之间高度相关,引发数值不稳定”,也是下一篇正则化要根治的病灶。
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是全文最核心的七行:逐样本逐幂次填充 。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
三张关键图:
怎么读这些数字和图
- 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 才会说”不”;两条曲线的分歧点就是过拟合现形的地方。但”换个阶数重训一遍”毕竟是笨办法——能不能在一个模型里同时容纳”拟合数据”与”克制参数”?能,给损失函数加一项惩罚即可。下一篇回归④:正则化三兄弟——岭回归、套索与弹性网络。