PRML 第三章:线性回归模型
给定一堆数据点,怎么画一条「最好」的直线?如果数据是弯的呢?如果画得太努力反而搞砸了呢?
假设你在买房。手上有一批数据:房子的面积和成交价。你发现面积越大价格越贵——能不能用面积来预测价格?
最蠢也最自然的想法:画一条直线穿过这些点。给定新面积$x$,直线告诉你价格:
$$ \text{价格} \approx w \cdot \text{面积} + b $$这条直线怎么画?怎么判断画得好不好?如果数据是弯的怎么办?如果直线画得太「努力」以至于在新数据上表现很差怎么办?
这些问题的答案,就在 PRML 第三章。这个 Notebook 对第三章做完整推导 + 手写实现 + 可视化:从最大似然出发,经基函数、正则化、偏差-方差分解,一路走到贝叶斯线性回归的预测分布。
环境准备
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
from manim import *
# ── 中文字体设置 ──
# 优先使用霞鹜文楷(更美观),回退到 Noto Sans CJK SC
_cjk_fonts = ['LXGW WenKai', 'Noto Sans CJK SC', 'HYWenHei']
_available = [f.name for f in matplotlib.font_manager.fontManager.ttflist]
_cjk = next((f for f in _cjk_fonts if f in _available), None)
if _cjk:
plt.rcParams['font.sans-serif'] = [_cjk, 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
print(f"中文字体: {_cjk}")
else:
print("⚠️ 未找到中文字体,图表中的中文可能无法正常显示")
# 全局设置
plt.rcParams.update({
'figure.figsize': (10, 6),
'font.size': 12,
'axes.labelsize': 14,
'axes.titlesize': 16,
})
np.random.seed(42)
print("NumPy version:", np.__version__)中文字体: LXGW WenKai
NumPy version: 2.5.1
线性基函数模型
PRML第三章的核心思想:模型对参数是线性的,但输入可以经过非线性基函数变换。我们从最简单的开始——先用原始输入$x$本身做线性回归,搞清楚最大似然、梯度下降这些基本工具,再加基函数。
造一批数据
假设真实关系是 $y = 3x + 5$,但每个观测都带着随机噪声(现实中的数据永远不干净):
$$ y = 3x + 5 + \varepsilon, \quad \varepsilon \sim \mathcal{N}(0, 4^2) $$$\sigma=4$ 故意选大——噪声太小的话随便画条线都差不多,看不到优化的过程。
# 真实参数
TRUE_W = 3.0
TRUE_B = 5.0
NOISE_STD = 4.0
# 生成数据
N = 100
X = np.random.uniform(-5, 5, N)
noise = np.random.normal(0, NOISE_STD, N)
y = TRUE_W * X + TRUE_B + noise
# 可视化
plt.figure(figsize=(8, 5))
plt.scatter(X, y, alpha=0.6, label='带噪声的数据点')
x_line = np.linspace(-6, 6, 100)
plt.plot(x_line, TRUE_W * x_line + TRUE_B, 'r--', linewidth=2, label=f'真实直线: y = {TRUE_W}x + {TRUE_B}')
plt.xlabel('x')
plt.ylabel('y')
plt.legend()
plt.title('带噪声的线性数据')
plt.grid(True, alpha=0.3)
plt.show()
print(f"数据量: N = {N}")
print(f"真实模型: y = {TRUE_W}x + {TRUE_B} + ε, ε ~ N(0, {NOISE_STD}²)")
数据量: N = 100
真实模型: y = 3.0x + 5.0 + ε, ε ~ N(0, 4.0²)
最大似然与最小二乘
PRML第三章的核心结论:在高斯噪声假设下,最大似然估计等价于最小二乘。
模型是$\hat{y} = wx + b$。怎么找到最好的参数?两个步骤:
1. 定义一个损失函数。 假设每个数据点的误差$\varepsilon_i = y_i - \left(wx_i + b\right)$独立同分布于$\mathcal{N}(0, \sigma^2)$。写出似然函数,取负对数——关于$w,b$的部分恰好是均方误差MSE:
$$L(w, b) = \frac{1}{N}\sum_{i=1}^N (wx_i + b - y_i)^2$$2. 用梯度下降优化。 对$L$求偏导,沿负梯度方向走:
$$\frac{\partial L}{\partial w} = \frac{2}{N}\sum_{i=1}^N (wx_i + b - y_i) x_i, \qquad \frac{\partial L}{\partial b} = \frac{2}{N}\sum_{i=1}^N (wx_i + b - y_i)$$直觉:梯度告诉你往哪走loss降最快。每一步沿着这个方向挪一点,直到走不动为止。
def compute_loss(X, y, w, b):
"""计算 MSE 损失"""
y_pred = w * X + b
return np.mean((y_pred - y) ** 2)
def compute_gradient(X, y, w, b):
"""计算 w 和 b 的梯度"""
N = len(X)
y_pred = w * X + b
error = y_pred - y
dw = (2 / N) * np.sum(error * X)
db = (2 / N) * np.sum(error)
return dw, db
def gradient_descent(X, y, w_init, b_init, lr, epochs, verbose=True):
"""梯度下降优化,返回权重历史与损失历史"""
w, b = w_init, b_init
w_history, b_history, loss_history = [w], [b], [compute_loss(X, y, w, b)]
for epoch in range(epochs):
dw, db = compute_gradient(X, y, w, b)
w -= lr * dw
b -= lr * db
w_history.append(w)
b_history.append(b)
loss_history.append(compute_loss(X, y, w, b))
if verbose and epoch % max(1, epochs // 10) == 0:
print(f"Epoch {epoch:4d}: loss={loss_history[-1]:.4f}, w={w:.4f}, b={b:.4f}")
return np.array(w_history), np.array(b_history), np.array(loss_history)
拟合结果
从 $w=0, b=0$ 出发,学习率 0.01,走 500 步。左边是最终的拟合直线 vs 真实直线,右边是训练过程中 loss 的下降曲线。
# 初始化与训练
w_init, b_init = 0.0, 0.0
lr = 0.01
epochs = 500
w_hist, b_hist, loss_hist = gradient_descent(X, y, w_init, b_init, lr, epochs)
print(f"\n训练完毕!")
print(f"学习到的参数: w = {w_hist[-1]:.4f}(真实值: {TRUE_W}), b = {b_hist[-1]:.4f}(真实值: {TRUE_B})")
print(f"最终损失: {loss_hist[-1]:.4f}")
Epoch 0: loss=78.4675, w=0.4690, b=0.0820
Epoch 50: loss=16.1855, w=2.7469, b=3.1207
Epoch 100: loss=13.3505, w=2.7907, b=4.2703
Epoch 150: loss=12.9658, w=2.8067, b=4.6938
Epoch 200: loss=12.9136, w=2.8126, b=4.8498
Epoch 250: loss=12.9065, w=2.8148, b=4.9073
Epoch 300: loss=12.9055, w=2.8156, b=4.9285
Epoch 350: loss=12.9054, w=2.8159, b=4.9363
Epoch 400: loss=12.9054, w=2.8160, b=4.9392
Epoch 450: loss=12.9054, w=2.8161, b=4.9402
训练完毕!
学习到的参数: w = 2.8161(真实值: 3.0), b = 4.9406(真实值: 5.0)
最终损失: 12.9054
# 可视化:最终拟合结果
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 左图:数据 + 拟合直线
ax = axes[0]
ax.scatter(X, y, alpha=0.5, label='数据点')
x_line = np.linspace(X.min() - 1, X.max() + 1, 100)
ax.plot(x_line, TRUE_W * x_line + TRUE_B, 'r--', linewidth=2, label=f'真实: y={TRUE_W}x+{TRUE_B}')
ax.plot(x_line, w_hist[-1] * x_line + b_hist[-1], 'g-', linewidth=2, label=f'拟合: y={w_hist[-1]:.2f}x+{b_hist[-1]:.2f}')
ax.set_xlabel('x'); ax.set_ylabel('y')
ax.legend(); ax.set_title('数据与拟合直线')
ax.grid(True, alpha=0.3)
# 右图:损失曲线
ax = axes[1]
ax.plot(loss_hist, 'b-', linewidth=1.5)
ax.set_xlabel('Epoch'); ax.set_ylabel('MSE Loss')
ax.set_title('训练损失曲线')
ax.set_yscale('log')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
%%manim -qm -r 960,540 -v WARNING GradientDescentTrajectory
config.media_embed = True
config.media_width = "100%"
config.verbosity = "WARNING"
class GradientDescentTrajectory(Scene):
def construct(self):
step = max(1, len(w_hist) // 100)
ws = w_hist[::step]
bs = b_hist[::step]
losses = loss_hist[::step]
x_min, x_max = X.min() - 1, X.max() + 1
y_min, y_max = y.min() - 3, y.max() + 3
axes = Axes(x_range=[x_min, x_max, 2], y_range=[y_min, y_max, 5],
x_length=8, y_length=5.5, tips=False).shift(DOWN * 0.3)
self.add(axes)
dots = VGroup(*[Dot(axes.coords_to_point(xi, yi), radius=0.04,
color=BLUE_C, fill_opacity=0.6) for xi, yi in zip(X, y)])
self.add(dots)
def make_line(w, b, c, wd):
return Line(axes.coords_to_point(x_min, w * x_min + b),
axes.coords_to_point(x_max, w * x_max + b),
color=c, stroke_width=wd)
self.add(make_line(TRUE_W, TRUE_B, RED, 3))
fit_line = make_line(ws[0], bs[0], YELLOW, 3)
self.add(fit_line)
# 右上角显示 w, b, loss
def make_info(i):
return VGroup(
Text(f"w = {ws[i]:.2f}, b = {bs[i]:.2f}", font_size=24, color=YELLOW),
Text(f"MSE = {losses[i]:.2f}", font_size=24, color=YELLOW),
).arrange(DOWN, aligned_edge=LEFT, buff=0.1).to_corner(UR, buff=0.3)
info = make_info(0)
self.add(info)
n = len(ws)
tracker = ValueTracker(0)
def upd_line(mob):
i = min(int(tracker.get_value()), n - 1)
mob.become(make_line(ws[i], bs[i], YELLOW, 3))
def upd_info(mob):
mob.become(make_info(min(int(tracker.get_value()), n - 1)))
self.play(
UpdateFromFunc(fit_line, upd_line),
UpdateFromFunc(info, upd_info),
tracker.animate.set_value(n - 1),
run_time=6, rate_func=smooth,
)
self.wait(1)Manim Community v0.20.1
MSE的概率解释
为什么是平方损失而不是绝对值?PRML §3.1.1 从最大似然估计(MLE)给出了严格的概率推导。
1. 概率模型
假设数据是这样生成的:存在一组真实参数$(w, b)$,每个观测$y_i$是$wx_i + b$加上一个随机扰动$\varepsilon_i$。我们假设扰动独立同分布于均值为0、方差为$\sigma^2$ 的高斯分布:
$$ y_i = wx_i + b + \varepsilon_i, \qquad \varepsilon_i \overset{\text{i.i.d.}}{\sim} \mathcal{N}(0, \sigma^2) $$这意味着每个$y_i$在给定$x_i$和参数$(w, b, \sigma^2)$下的条件分布也是高斯:
$$ p(y_i \mid x_i, w, b, \sigma^2) = \mathcal{N}(y_i \mid wx_i + b,\; \sigma^2) = \frac{1}{\sqrt{2\pi\sigma^2}} \exp\!\left(-\frac{(wx_i + b - y_i)^2}{2\sigma^2}\right) $$2. 似然函数
由于$\varepsilon_i$相互独立,所有$y_i$在给定$\mathbf{x}$下的联合概率等于各自条件概率的乘积:
$$ p(\mathbf{y} \mid \mathbf{x}, w, b, \sigma^2) = \prod_{i=1}^N \mathcal{N}(y_i \mid wx_i + b, \sigma^2) = \prod_{i=1}^N \frac{1}{\sqrt{2\pi\sigma^2}} \exp\!\left(-\frac{(wx_i + b - y_i)^2}{2\sigma^2}\right) $$这就是似然函数(likelihood):在给定参数$(w, b, \sigma^2)$下,观测到这批数据的概率。
3. 最大似然估计
最大似然估计的目标很直观:找一组参数使得「观测到当前数据」这件事的概率最大:
$$ (w_{\text{MLE}}, b_{\text{MLE}}) = \arg\max_{w,b}\; p(\mathbf{y} \mid \mathbf{x}, w, b, \sigma^2) $$直接优化连乘积不方便(指数相乘很繁琐),但$\log$是单调递增函数,最大化$p$等价于最大化$\log p$。取对数把连乘变成求和:
$$ \begin{aligned} \log p(\mathbf{y} \mid \mathbf{x}, w, b, \sigma^2) &= \sum_{i=1}^N \log \left[ \frac{1}{\sqrt{2\pi\sigma^2}} \exp\!\left(-\frac{(wx_i + b - y_i)^2}{2\sigma^2}\right) \right] \\[4pt] &= \sum_{i=1}^N \left[ -\frac{1}{2}\log(2\pi\sigma^2) - \frac{(wx_i + b - y_i)^2}{2\sigma^2} \right] \\[4pt] &= -\frac{N}{2}\log(2\pi\sigma^2) - \frac{1}{2\sigma^2}\sum_{i=1}^N (wx_i + b - y_i)^2 \end{aligned} $$最大化$\log p$等价于最小化负对数似然(NLL):
$$ -\log p(\mathbf{y} \mid \mathbf{x}, w, b, \sigma^2) = \underbrace{\frac{N}{2}\log(2\pi\sigma^2)}_{\text{与 }w,b\text{ 无关}} + \frac{1}{2\sigma^2}\underbrace{\sum_{i=1}^N (wx_i + b - y_i)^2}_{\text{残差平方和}} $$第一项与$w,b$无关,第二项中$\frac{1}{2\sigma^2}$是正常数。因此:
$$ \boxed{\arg\max_{w,b}\; p(\mathbf{y} \mid \mathbf{x}, w, b, \sigma^2) = \arg\min_{w,b}\; \sum_{i=1}^N (wx_i + b - y_i)^2} $$除以$N$取平均就是MSE。在“误差服从高斯分布”的假设下,最大似然估计等价于最小二乘。
如果你接受误差是高斯分布,那MSE不是“一个选择”,而是唯一正确的选择(在MLE意义下)。损失函数从来不是随便选的——它编码了你对数据生成过程的信念。
4. 如果误差不是高斯呢?
换一个噪声假设,最优的损失函数就变了。
假设误差服从拉普拉斯分布 $\varepsilon_i \sim \text{Laplace}(0, b)$,其概率密度为$p(\varepsilon) = \frac{1}{2b}\exp(-|\varepsilon|/b)$。重复上面的推导:
$$ p(\mathbf{y} \mid \mathbf{x}, w, b) = \prod_{i=1}^N \frac{1}{2b}\exp\!\left(-\frac{|wx_i + b - y_i|}{b}\right) $$取负对数:
$$ -\log p = N\log(2b) + \frac{1}{b}\sum_{i=1}^N |wx_i + b - y_i| $$最大化似然$\Leftrightarrow$最小化$\sum |wx_i + b - y_i|$——这就是MAE(平均绝对误差)。
| 噪声假设 | 最优损失 | 特点 |
|---|---|---|
| 高斯 $\mathcal{N}(0,\sigma^2)$ | MSE(平方) | 对离群点敏感,大误差惩罚极重 |
| 拉普拉斯 $\text{Laplace}(0,b)$ | MAE(绝对值) | 对离群点鲁棒——想想「急用钱低价抛售」的异常房源 |
5. 顺便提一下:解析解
对于线性回归$y = wx + b$,MSE是$w$和$b$的二次函数——有一个唯一的全局最小值。令梯度为零可以直接解出闭式解(normal equations),不需要迭代的梯度下降。我们之所以用梯度下降来做,是因为它是一种普适方法——当模型变复杂(比如神经网络)、闭式解不存在的时候,你仍然可以用梯度下降。从现在开始习惯它的行为,后面就顺了。
下面做实验:把噪声从高斯换成拉普拉斯,对比 MSE 和 MAE。
# 对比:高斯噪声 vs 拉普拉斯噪声下,MSE 和 MAE 的表现
def gradient_descent_mae(X, y, w_init, b_init, lr, epochs):
"""使用 MAE 损失的梯度下降(次梯度)"""
w, b = w_init, b_init
w_hist, b_hist, loss_hist = [w], [b], [np.mean(np.abs(w * X + b - y))]
for _ in range(epochs):
y_pred = w * X + b
error = y_pred - y
# MAE 的次梯度:sign(error)
dw = np.mean(np.sign(error) * X)
db = np.mean(np.sign(error))
w -= lr * dw
b -= lr * db
w_hist.append(w); b_hist.append(b)
loss_hist.append(np.mean(np.abs(error)))
return np.array(w_hist), np.array(b_hist), np.array(loss_hist)
# 生成拉普拉斯噪声数据(scale 比高斯 σ 大,且手动加几个极端离群点)
np.random.seed(123)
noise_laplace = np.random.laplace(0, NOISE_STD * 1.5, N) # 拉普拉斯 scale 更大
y_laplace = TRUE_W * X + TRUE_B + noise_laplace
# 手动注入几个极端离群点——模拟「急用钱低价抛售」的异常房源
outlier_idx = [10, 45, 78]
y_laplace[outlier_idx] = TRUE_W * X[outlier_idx] + TRUE_B + np.array([25, -20, 18])
# 用 MSE 梯度下降拟合拉普拉斯噪声数据
w_mse, b_mse, _ = gradient_descent(X, y_laplace, 0.0, 0.0, 0.01, 1000)
# 用 MAE 梯度下降拟合拉普拉斯噪声数据(MAE 收敛慢,多跑几步)
w_mae, b_mae, _ = gradient_descent_mae(X, y_laplace, 0.0, 0.0, 0.005, 2000)
print(f"\n真实参数: w={TRUE_W}, b={TRUE_B}")
print(f"MSE 拟合: w={w_mse[-1]:.3f}, b={b_mse[-1]:.3f} (被离群点拉偏了)")
print(f"MAE 拟合: w={w_mae[-1]:.3f}, b={b_mae[-1]:.3f} (更接近真实值)")
# 可视化对比
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 左图:高斯噪声数据(MSE 最优)
ax = axes[0]
ax.scatter(X, y, alpha=0.5, label='数据(高斯噪声)')
ax.plot(x_line, TRUE_W * x_line + TRUE_B, 'r--', lw=2, label='真实直线')
ax.plot(x_line, w_hist[-1] * x_line + b_hist[-1], 'g-', lw=2, label=f'MSE 拟合: y={w_hist[-1]:.2f}x+{b_hist[-1]:.2f}')
ax.legend(); ax.set_title('高斯噪声 → MSE 拟合')
ax.grid(True, alpha=0.3)
# 右图:拉普拉斯噪声 + 离群点 → MAE 更鲁棒
ax = axes[1]
ax.scatter(X, y_laplace, alpha=0.5, label='数据(含离群点)', c='gray')
# 高亮离群点
ax.scatter(X[outlier_idx], y_laplace[outlier_idx], alpha=1.0, s=100,
label='离群点', c='red', marker='x', linewidths=2)
ax.plot(x_line, TRUE_W * x_line + TRUE_B, 'r--', lw=2, label='真实直线')
ax.plot(x_line, w_mse[-1] * x_line + b_mse[-1], 'g-', lw=2, label=f'MSE: y={w_mse[-1]:.2f}x+{b_mse[-1]:.2f}')
ax.plot(x_line, w_mae[-1] * x_line + b_mae[-1], 'orange', lw=2, label=f'MAE: y={w_mae[-1]:.2f}x+{b_mae[-1]:.2f}')
ax.legend(); ax.set_title('拉普拉斯噪声 + 离群点 → MAE 更鲁棒')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Epoch 0: loss=112.8547, w=0.4021, b=0.0942
Epoch 100: loss=54.0277, w=2.4273, b=4.7073
Epoch 200: loss=53.5021, w=2.4514, b=5.3429
Epoch 300: loss=53.4924, w=2.4547, b=5.4292
Epoch 400: loss=53.4922, w=2.4551, b=5.4409
Epoch 500: loss=53.4922, w=2.4552, b=5.4425
Epoch 600: loss=53.4922, w=2.4552, b=5.4427
Epoch 700: loss=53.4922, w=2.4552, b=5.4427
Epoch 800: loss=53.4922, w=2.4552, b=5.4427
Epoch 900: loss=53.4922, w=2.4552, b=5.4427
真实参数: w=3.0, b=5.0
MSE 拟合: w=2.455, b=5.443 (被离群点拉偏了)
MAE 拟合: w=2.525, b=4.077 (更接近真实值)
最小二乘的几何——换个角度看
刚才用微积分推导 MSE → 梯度下降。但如果你喜欢几何,还有一个更优雅的理解方式(PRML §3.1.2)。
把设计矩阵写成 $\Phi$($N$ 行 $D$ 列,每行一个样本),目标向量 $\mathbf{y} \in \mathbb{R}^N$。预测值是 $\Phi\mathbf{w}$——它是 $\Phi$ 的列向量的线性组合,所以落在 $\Phi$ 的列空间里。
最小二乘在干什么?在列空间里找一个向量,让它离 $\mathbf{y}$ 最近。最近的那个,就是 $\mathbf{y}$ 在列空间上的正交投影。
解正规方程 $\Phi^\top\Phi\mathbf{w} = \Phi^\top\mathbf{y}$ 得到 $\mathbf{w}$,那么:
$$\Phi\mathbf{w} = \underbrace{\Phi(\Phi^\top\Phi)^{-1}\Phi^\top}_{\text{投影矩阵 }P}\ \mathbf{y}$$残差 $\mathbf{y} - \Phi\mathbf{w}$ 垂直于列空间里的每一个向量。正规方程 $\Phi^\top(\Phi\mathbf{w} - \mathbf{y}) = 0$ 翻译成人话就是「残差跟所有特征向量都正交」。PRML Fig 3.2 画得很清楚。
下面画一个 $N=3$、$D=2$($w$ 和 $b$)的例子——三个数据点在 $\mathbb{R}^3$ 中的向量,$\Phi$ 的列空间是一个平面,最小二乘就是 $\mathbf{y}$ 在这个平面上的投影。
# 最小二乘几何:N=3个数据点,D=2个参数(w, b)
# 在 R^3 中,Phi 的列空间是一个过原点的平面
# y 在这个平面上的投影就是最小二乘解
from mpl_toolkits.mplot3d import Axes3D
# 取前 3 个数据点做可视化
X3d = X[:3]
y3d = y[:3]
# 设计矩阵 [1, x]
Phi_3d = np.column_stack([np.ones(3), X3d])
# 正规方程求解
w_ols = np.linalg.solve(Phi_3d.T @ Phi_3d, Phi_3d.T @ y3d)
y_proj = Phi_3d @ w_ols
print(f"OLS 解: w={w_ols[1]:.3f}, b={w_ols[0]:.3f}")
print(f"y = {y3d}")
print(f"投影 y_hat = {y_proj}")
print(f"残差 = {y3d - y_proj}")
print(f"残差 ⋅ 列1 = {np.dot(y3d - y_proj, Phi_3d[:, 0]):.2e} (≈0 ✓)")
print(f"残差 ⋅ 列2 = {np.dot(y3d - y_proj, Phi_3d[:, 1]):.2e} (≈0 ✓)")
# 3D 可视化
fig = plt.figure(figsize=(12, 10))
ax = fig.add_subplot(111, projection='3d')
# 列空间平面: span{(1,1,1), (x1,x2,x3)} 由 Phi 的两列张成
# 参数化: u*[1,1,1] + v*[x1,x2,x3]
u = np.linspace(-5, 5, 20)
v = np.linspace(-5, 5, 20)
U, V = np.meshgrid(u, v)
plane_x = U * Phi_3d[0, 0] + V * Phi_3d[0, 1]
plane_y = U * Phi_3d[1, 0] + V * Phi_3d[1, 1]
plane_z = U * Phi_3d[2, 0] + V * Phi_3d[2, 1]
ax.plot_surface(plane_x, plane_y, plane_z, alpha=0.2, color='blue')
# 原点
ax.scatter([0], [0], [0], color='black', s=80, marker='o')
ax.text(0, 0, 0, ' O', fontsize=12)
# y 向量
ax.quiver(0, 0, 0, y3d[0], y3d[1], y3d[2],
color='red', linewidth=2, arrow_length_ratio=0.1, label='y')
ax.text(y3d[0], y3d[1], y3d[2], ' y', color='red', fontsize=14)
# 投影 y_hat
ax.quiver(0, 0, 0, y_proj[0], y_proj[1], y_proj[2],
color='green', linewidth=2, arrow_length_ratio=0.1, label='ŷ = Proj(y)')
ax.text(y_proj[0], y_proj[1], y_proj[2], ' ŷ', color='green', fontsize=14)
# 残差(从 y_hat 到 y)
ax.quiver(y_proj[0], y_proj[1], y_proj[2],
y3d[0] - y_proj[0], y3d[1] - y_proj[1], y3d[2] - y_proj[2],
color='orange', linewidth=2, arrow_length_ratio=0.1, label='残差 (⊥ 列空间)')
# 列空间的两个基向量
col1 = Phi_3d[:, 0] * 3 # 缩放一下方便看
col2 = Phi_3d[:, 1] * 3
ax.quiver(0, 0, 0, col1[0], col1[1], col1[2],
color='blue', linewidth=1.5, alpha=0.6, arrow_length_ratio=0.1)
ax.quiver(0, 0, 0, col2[0], col2[1], col2[2],
color='blue', linewidth=1.5, alpha=0.6, arrow_length_ratio=0.1)
ax.set_xlabel('维度 1'); ax.set_ylabel('维度 2'); ax.set_zlabel('维度 3')
ax.set_title('最小二乘的几何:ŷ 是 y 在 Φ 列空间上的正交投影')
ax.legend()
plt.show()
OLS 解: w=2.759, b=5.288
y = [ 1.58439184 17.32539979 12.32686136]
投影 y_hat = [ 1.8269245 17.72176996 11.68795853]
残差 = [-0.24253266 -0.39637016 0.63890283]
残差 ⋅ 列1 = -4.00e-15 (≈0 ✓)
残差 ⋅ 列2 = -1.02e-14 (≈0 ✓)
每次只看一个点——随机梯度下降
刚才的梯度下降每步扫全部 $N$ 个数据算梯度。$N$ 到了百万级就慢了。
随机梯度下降(SGD,PRML §3.1.3 叫「序贯学习」)换个思路:每次随机挑一个点,只拿这一个点算梯度,立刻更新。 下一步再挑另一个。
$$w \leftarrow w - \eta \cdot 2(w x_i + b - y_i)\, x_i$$它吵、它晃、它不会老老实实收敛到同一个点——但每步计算量是 $O(1)$ 而不是 $O(N)$。数据量大的时候,SGD 扫一圈(一个 epoch)的进度远超批量梯度下降。
SGD 还有一个隐藏好处:梯度噪声本身成了一种隐式正则化——不容易卡在鞍点。这也是为什么深度学习里清一色用 SGD 变体。
下面拿同一份数据对比:批量梯度下降 vs SGD。
def sgd(X, y, w_init, b_init, lr, epochs):
"""随机梯度下降:每步只看一个数据点"""
w, b = w_init, b_init
N = len(X)
w_hist, b_hist, loss_hist = [w], [b], [compute_loss(X, y, w, b)]
for epoch in range(epochs):
i = np.random.randint(0, N) # 随机挑一个点
xi, yi = X[i], y[i]
error = w * xi + b - yi
w -= lr * 2 * error * xi
b -= lr * 2 * error
w_hist.append(w)
b_hist.append(b)
loss_hist.append(compute_loss(X, y, w, b))
return np.array(w_hist), np.array(b_hist), np.array(loss_hist)
# 对比:批量梯度下降 vs SGD
w_batch, b_batch, loss_batch = gradient_descent(X, y, 0.0, 0.0, lr=0.01, epochs=500)
w_sgd, b_sgd, loss_sgd = sgd(X, y, 0.0, 0.0, lr=0.01, epochs=500)
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# Loss 曲线
ax = axes[0]
ax.plot(loss_batch, 'b-', alpha=0.7, linewidth=1.5, label='批量 GD')
ax.plot(loss_sgd, 'r-', alpha=0.5, linewidth=0.8, label='SGD')
ax.set_yscale('log')
ax.set_xlabel('Epoch'); ax.set_ylabel('MSE Loss')
ax.set_title('Loss 收敛对比')
ax.legend(); ax.grid(True, alpha=0.3)
# w 轨迹
ax = axes[1]
ax.plot(w_batch, 'b-', alpha=0.7, label='批量 GD')
ax.plot(w_sgd, 'r-', alpha=0.5, label='SGD')
ax.axhline(y=TRUE_W, color='gray', linestyle='--', label=f'真实 w={TRUE_W}')
ax.set_xlabel('Epoch'); ax.set_ylabel('w')
ax.set_title('w 的收敛轨迹(SGD 更吵但方向对)')
ax.legend(); ax.grid(True, alpha=0.3)
# b 轨迹
ax = axes[2]
ax.plot(b_batch, 'b-', alpha=0.7, label='批量 GD')
ax.plot(b_sgd, 'r-', alpha=0.5, label='SGD')
ax.axhline(y=TRUE_B, color='gray', linestyle='--', label=f'真实 b={TRUE_B}')
ax.set_xlabel('Epoch'); ax.set_ylabel('b')
ax.set_title('b 的收敛轨迹')
ax.legend(); ax.grid(True, alpha=0.3)
plt.suptitle('批量梯度下降 vs 随机梯度下降(SGD 吵但省计算)', fontsize=14, y=1.02)
plt.tight_layout()
plt.show()
print(f"批量 GD 最终: w={w_batch[-1]:.4f}, b={b_batch[-1]:.4f}")
print(f"SGD 最终: w={w_sgd[-1]:.4f}, b={b_sgd[-1]:.4f}")
print(f"真实值: w={TRUE_W}, b={TRUE_B}")
Epoch 0: loss=78.4675, w=0.4690, b=0.0820
Epoch 50: loss=16.1855, w=2.7469, b=3.1207
Epoch 100: loss=13.3505, w=2.7907, b=4.2703
Epoch 150: loss=12.9658, w=2.8067, b=4.6938
Epoch 200: loss=12.9136, w=2.8126, b=4.8498
Epoch 250: loss=12.9065, w=2.8148, b=4.9073
Epoch 300: loss=12.9055, w=2.8156, b=4.9285
Epoch 350: loss=12.9054, w=2.8159, b=4.9363
Epoch 400: loss=12.9054, w=2.8160, b=4.9392
Epoch 450: loss=12.9054, w=2.8161, b=4.9402
批量 GD 最终: w=2.8161, b=4.9406
SGD 最终: w=2.7269, b=4.8018
真实值: w=3.0, b=5.0
基函数:让输入变丰富
上面的模型只能拟合直线。但如果真实关系是$y = \sin(x)$这种弯的呢?
PRML §3.1 的关键洞察:不换模型,换输入。 把原始$x$映射成一组基函数$\phi_j(x)$,然后对$\phi(x)$做线性回归:
$$\hat{y}(x, \mathbf{w}) = \sum_{j=0}^{d} w_j \phi_j(x) = \mathbf{w}^\top \phi(x)$$最常用的基函数是多项式$\phi_j(x) = x^j$:
$$\hat{y} = w_0 + w_1 x + w_2 x^2 + \cdots + w_d x^d$$用$y = \sin(x) + \text{noise}$来测试。
对$\mathbf{w}$这仍然是线性模型——但因为输入被升维了,在原空间里拟合出来的是曲线。
$x^9$、$x^{15}$数值范围天差地别——$3^9=19683$而$3^{15}\approx 1.4\times 10^7$。不做标准化梯度直接爆炸。
PRML Fig 3.1 画了 $d=0,1,3,9$ 的多项式拟合 $\sin$ 函数的经典图。我们下面复现一下。>
# 生成非线性数据:y = sin(x) + noise
N2 = 100
X2 = np.random.uniform(-3, 3, N2)
y2 = np.sin(X2) + np.random.normal(0, 0.15, N2)
plt.figure(figsize=(8, 5))
x_smooth = np.linspace(-3.5, 3.5, 200)
plt.plot(x_smooth, np.sin(x_smooth), 'r--', linewidth=2, label='真实函数: y = sin(x)')
plt.scatter(X2, y2, alpha=0.6, label='带噪声的数据点')
plt.xlabel('x'); plt.ylabel('y')
plt.legend(); plt.title('第 2 周:非线性数据')
plt.grid(True, alpha=0.3)
plt.show()
基函数实现
def poly_features(X, degree):
"""将 X 展开为多项式特征矩阵 [1, x, x^2, ..., x^degree]"""
return np.vstack([X ** d for d in range(degree + 1)]).T # shape: (N, degree+1)
def standardize(Phi):
"""对设计矩阵做标准化(每列减均值除以标准差),第一列(偏置列)不处理"""
mean = np.mean(Phi[:, 1:], axis=0)
std = np.std(Phi[:, 1:], axis=0)
std[std == 0] = 1.0 # 防止除以 0
Phi_scaled = Phi.copy()
Phi_scaled[:, 1:] = (Phi[:, 1:] - mean) / std
return Phi_scaled, mean, std
def poly_gradient_descent(Phi, y, lr, epochs, standardize_features=True, verbose=True):
"""对设计矩阵 Phi 做线性回归的梯度下降(可选标准化)"""
if standardize_features and Phi.shape[1] > 1:
Phi, mean, std = standardize(Phi)
else:
mean, std = None, None
N, D = Phi.shape
w = np.zeros(D)
loss_history = [np.mean((Phi @ w - y) ** 2)]
for epoch in range(epochs):
error = Phi @ w - y
grad = (2 / N) * Phi.T @ error
w -= lr * grad
loss_history.append(np.mean((Phi @ w - y) ** 2))
if verbose and epoch % max(1, epochs // 10) == 0:
print(f" Epoch {epoch:4d}: loss={loss_history[-1]:.6f}")
return w, np.array(loss_history), mean, std
def poly_predict(X, w, mean=None, std=None):
"""用多项式权重做预测(支持反标准化)"""
degree = len(w) - 1
Phi = poly_features(X, degree)
if mean is not None and std is not None:
Phi_scaled = Phi.copy()
Phi_scaled[:, 1:] = (Phi[:, 1:] - mean) / std
return Phi_scaled @ w
return Phi @ w
$d=1,3,5,9,15$:复现 PRML Fig 3.1
PRML Fig 3.1 的经典结论:$d$ 太小欠拟合,$d$ 太大过拟合。$d=3$ 附近刚好。
# 用不同多项式次数拟合,观察过拟合
degrees = [1, 3, 5, 9, 15]
lr_poly = 0.1
epochs_poly = 2000
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()
results = {}
for idx, d in enumerate(degrees):
Phi = poly_features(X2, d)
w, loss_hist, mean, std = poly_gradient_descent(Phi, y2, lr_poly, epochs_poly)
# 预测
X_test = np.linspace(-3.5, 3.5, 200)
y_pred = poly_predict(X_test, w, mean, std)
# 训练集上的预测(用标准化后的 Phi)
if d > 1:
Phi_scaled = Phi.copy()
Phi_scaled[:, 1:] = (Phi[:, 1:] - mean) / std
y_train_pred = Phi_scaled @ w
else:
y_train_pred = Phi @ w
train_loss = np.mean((y_train_pred - y2) ** 2)
# 画拟合曲线
ax = axes[idx]
ax.scatter(X2, y2, alpha=0.5, s=20, label='训练数据')
ax.plot(x_smooth, np.sin(x_smooth), 'r--', lw=2, label='真实 sin(x)')
ax.plot(X_test, y_pred, 'g-', lw=2, label=f'd={d} 拟合')
ax.set_xlim(-3.5, 3.5); ax.set_ylim(-1.8, 1.8)
ax.set_title(f'次数 d = {d} | Train Loss = {train_loss:.4f}')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
results[d] = {'w': w, 'loss': train_loss, 'loss_hist': loss_hist, 'mean': mean, 'std': std}
# 最后一个子图:各次数的训练损失对比
ax = axes[-1]
for d in degrees:
ax.plot(results[d]['loss_hist'], label=f'd={d}', alpha=0.8)
ax.set_xlabel('Epoch'); ax.set_ylabel('MSE Loss')
ax.set_title('各次数训练损失曲线')
ax.set_yscale('log')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
plt.suptitle('多项式拟合:从欠拟合到过拟合', fontsize=18, y=1.02)
plt.tight_layout()
plt.show()
Epoch 0: loss=0.387556
Epoch 200: loss=0.177530
Epoch 400: loss=0.177530
Epoch 600: loss=0.177530
Epoch 800: loss=0.177530
Epoch 1000: loss=0.177530
Epoch 1200: loss=0.177530
Epoch 1400: loss=0.177530
Epoch 1600: loss=0.177530
Epoch 1800: loss=0.177530
Epoch 0: loss=0.351870
Epoch 200: loss=0.023066
Epoch 400: loss=0.022547
Epoch 600: loss=0.022546
Epoch 800: loss=0.022546
Epoch 1000: loss=0.022546
Epoch 1200: loss=0.022546
Epoch 1400: loss=0.022546
Epoch 1600: loss=0.022546
Epoch 1800: loss=0.022546
Epoch 0: loss=0.343328
Epoch 200: loss=0.031097
Epoch 400: loss=0.027893
Epoch 600: loss=0.025706
Epoch 800: loss=0.024213
Epoch 1000: loss=0.023194
Epoch 1200: loss=0.022498
Epoch 1400: loss=0.022023
Epoch 1600: loss=0.021699
Epoch 1800: loss=0.021477
Epoch 0: loss=0.352189
Epoch 200: loss=0.029520
Epoch 400: loss=0.022562
Epoch 600: loss=0.021594
Epoch 800: loss=0.021441
Epoch 1000: loss=0.021399
Epoch 1200: loss=0.021374
Epoch 1400: loss=0.021352
Epoch 1600: loss=0.021331
Epoch 1800: loss=0.021312
Epoch 0: loss=0.379609
Epoch 200: loss=0.025820
Epoch 400: loss=0.024015
Epoch 600: loss=0.023201
Epoch 800: loss=0.022612
Epoch 1000: loss=0.022182
Epoch 1200: loss=0.021867
Epoch 1400: loss=0.021636
Epoch 1600: loss=0.021466
Epoch 1800: loss=0.021342
过拟合的数值表现:权重暴涨
PRML §3.1 指出:高次多项式的权重范数 $\|\mathbf{w}\|_2$ 会急剧增大。模型在用极端大的权重去「强行穿过」每一个训练点——这就是过拟合的数值面目。
自然想到:能不能限制权重的大小?
# 可视化各次数下权重的范数
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 左图:权重 L2 范数
ax = axes[0]
l2_norms = [np.linalg.norm(results[d]['w']) for d in degrees]
bars = ax.bar(degrees, l2_norms, color=['steelblue', 'steelblue', 'steelblue', 'coral', 'firebrick'])
ax.set_xlabel('多项式次数 d'); ax.set_ylabel('||w||₂')
ax.set_title('权重 L2 范数随多项式次数增长')
for bar, val in zip(bars, l2_norms):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3, f'{val:.1f}', ha='center', fontsize=10)
# 右图:各次数权重的具体值
ax = axes[1]
for d in degrees:
w = results[d]['w']
ax.plot(range(len(w)), w, 'o-', markersize=6, label=f'd={d}', alpha=0.8)
ax.axhline(y=0, color='gray', linestyle='-', alpha=0.3)
ax.set_xlabel('权重索引'); ax.set_ylabel('权重值')
ax.set_title('各次数下权重分布')
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print("结论:d 越大,||w||₂ 越大 → 模型越容易对训练数据「死记硬背」 → 过拟合")
print("解决思路:在损失函数中加罚项 λ||w||² → 这就是 L2 正则化(Ridge)")
结论:d 越大,||w||₂ 越大 → 模型越容易对训练数据「死记硬背」 → 过拟合
解决思路:在损失函数中加罚项 λ||w||² → 这就是 L2 正则化(Ridge)
§3.1.4 正则化最小二乘
PRML §3.1.4:在损失函数中加入对权重的惩罚。这就是 Ridge 回归:
$$L_{\text{ridge}}(\mathbf{w}) = \frac{1}{N}\sum_{i=1}^N (y_i - \mathbf{w}^\top\phi(x_i))^2 + \lambda\|\mathbf{w}\|_2^2$$$\lambda$ 控制惩罚力度。$\lambda=0$ 就是原来的过拟合;$\lambda$ 越大曲线越平滑。
但为什么偏偏是 $\lambda\|\mathbf{w}\|_2^2$ 这种形式?PRML §3.3 从贝叶斯角度给了一个漂亮的解释——我们稍后细说。先看效果。
正则化是怎么起作用的? 梯度下降的更新规则本来是这样:
$$w_j \leftarrow w_j - \eta \cdot \underbrace{\frac{\partial\,\text{MSE}}{\partial w_j}}_{\text{数据说了算}}$$加上 L2 罚项后,多了一项:
$$w_j \leftarrow w_j - \eta \cdot \left( \underbrace{\frac{\partial\,\text{MSE}}{\partial w_j}}_{\text{拟合数据}} + \underbrace{2\lambda w_j}_{\text{拉回原点}} \right)$$每一步更新,除了「数据让你往哪走」,还有一个 $2\lambda w_j$ 的力把你往 0 拽。权重越大,拽得越狠——这就是为什么大权重被压制、小权重不受影响。下面实验可以直观验证。
下面手写 Ridge 梯度下降,看 $\lambda$ 从 $0$ 调到 $1$ 时,$d=15$ 那条疯狂震荡的曲线如何被「掰直」。
def ridge_gradient_descent(Phi, y, lr, epochs, lam, standardize_features=True):
"""带 L2 正则化的梯度下降(Ridge),可选标准化"""
if standardize_features and Phi.shape[1] > 1:
Phi, mean, std = standardize(Phi)
else:
mean, std = None, None
N, D = Phi.shape
w = np.zeros(D)
loss_history = []
for epoch in range(epochs):
error = Phi @ w - y
# 梯度 = MSE 梯度 + λ * 2w(L2 惩罚的梯度)
grad_mse = (2 / N) * Phi.T @ error
grad_l2 = 2 * lam * w
grad = grad_mse + grad_l2
w -= lr * grad
mse = np.mean(error ** 2)
l2_penalty = lam * np.sum(w ** 2)
loss_history.append(mse + l2_penalty)
return w, np.array(loss_history), mean, std
# 用 d=15 的高次多项式,对比有无 L2 正则化
d_high = 15
Phi_high = poly_features(X2, d_high)
# 无正则化(用标准化)
w_no_reg, loss_no_reg, mean_ridge, std_ridge = poly_gradient_descent(Phi_high, y2, lr=0.1, epochs=3000, standardize_features=True)
# 有正则化(不同 λ)
lambdas = [0.0, 0.001, 0.01, 0.1, 1.0]
ridge_results = {}
for lam in lambdas:
if lam == 0.0:
w_r, loss_r = w_no_reg, loss_no_reg
else:
w_r, loss_r, _, _ = ridge_gradient_descent(Phi_high, y2, lr=0.1, epochs=3000, lam=lam, standardize_features=True)
ridge_results[lam] = {'w': w_r, 'loss': loss_r}
Epoch 0: loss=0.379609
Epoch 300: loss=0.024582
Epoch 600: loss=0.023201
Epoch 900: loss=0.022380
Epoch 1200: loss=0.021867
Epoch 1500: loss=0.021544
Epoch 1800: loss=0.021342
Epoch 2100: loss=0.021214
Epoch 2400: loss=0.021133
Epoch 2700: loss=0.021081
# 展示:正则化如何在梯度更新中压制大权重
# 取 d=15 训练的第一步,对比 λ=0 和 λ=1.0 的梯度
# 标准化 Phi_high(和前面的 Ridge 训练保持一致)
Phi_s = Phi_high.copy()
Phi_s[:, 1:] = (Phi_s[:, 1:] - mean_ridge) / std_ridge
w0 = np.zeros(Phi_s.shape[1])
# MSE 梯度(不含正则化)
err = Phi_s @ w0 - y2
grad_mse = (2 / len(y2)) * Phi_s.T @ err
# L2 惩罚梯度 = 2λw,初始 w=0 时惩罚梯度也是 0
# 但跑几步之后 w 变大了,惩罚梯度就开始起作用
# 我们模拟跑 10 步后的状态
w_10 = w0.copy()
for _ in range(10):
err = Phi_s @ w_10 - y2
w_10 -= 0.1 * ((2/len(y2)) * Phi_s.T @ err) # 只用 MSE
# 此时分别计算 MSE 梯度和 L2 惩罚梯度
err_10 = Phi_s @ w_10 - y2
grad_mse_10 = (2 / len(y2)) * Phi_s.T @ err_10
grad_l2_10 = 2 * 1.0 * w_10 # λ=1.0 时的惩罚梯度
# 画前 10 个权重的梯度对比
fig, ax = plt.subplots(figsize=(10, 5))
x = np.arange(10)
w = 0.35
ax.bar(x - w/2, np.abs(grad_mse_10[:10]), w, label='MSE 梯度(数据要你改多少)', color='steelblue')
ax.bar(x + w/2, np.abs(grad_l2_10[:10]), w, label='L2 惩罚梯度(λ=1.0 拉回零的力)', color='coral')
ax.set_xlabel('权重索引'); ax.set_ylabel('梯度绝对值')
ax.set_title('梯度分解:MSE 想放大权重,L2 把它们拽回来')
ax.legend(); ax.grid(True, alpha=0.3)
plt.show()
print("MSE 梯度让权重变大(拟合数据),L2 惩罚梯度反向拉回(防止过拟合)。")
print("权重越大 → L2 惩罚梯度越大 → 被拉回越狠。这就是正则化的机制。")MSE 梯度让权重变大(拟合数据),L2 惩罚梯度反向拉回(防止过拟合)。
权重越大 → L2 惩罚梯度越大 → 被拉回越狠。这就是正则化的机制。
# 可视化:不同 λ 下 d=15 多项式的拟合效果
X_test = np.linspace(-3.5, 3.5, 200)
Phi_test = poly_features(X_test, d_high)
# 标准化测试特征
Phi_test_scaled = Phi_test.copy()
Phi_test_scaled[:, 1:] = (Phi_test[:, 1:] - mean_ridge) / std_ridge
# 标准化训练特征
Phi_high_scaled = Phi_high.copy()
Phi_high_scaled[:, 1:] = (Phi_high[:, 1:] - mean_ridge) / std_ridge
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()
for idx, lam in enumerate(lambdas):
w = ridge_results[lam]['w']
y_pred = Phi_test_scaled @ w
train_pred = Phi_high_scaled @ w
train_mse = np.mean((train_pred - y2) ** 2)
ax = axes[idx]
ax.scatter(X2, y2, alpha=0.5, s=20, label='训练数据')
ax.plot(x_smooth, np.sin(x_smooth), 'r--', lw=2, label='真实 sin(x)')
ax.plot(X_test, y_pred, 'g-', lw=2, label=f'λ={lam}')
ax.set_xlim(-3.5, 3.5); ax.set_ylim(-1.8, 1.8)
title = '无正则化 (过拟合!)' if lam == 0.0 else f'Ridge λ={lam}'
ax.set_title(f'{title}\n||w||₂ = {np.linalg.norm(w):.2f}, Train MSE = {train_mse:.4f}')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
# 最后一个子图:各 λ 下的权重分布
ax = axes[-1]
for lam in lambdas:
w = ridge_results[lam]['w']
ax.plot(range(len(w)), w, 'o-', markersize=4, label=f'λ={lam}', alpha=0.8)
ax.axhline(y=0, color='gray', linestyle='-', alpha=0.3)
ax.set_xlabel('权重索引'); ax.set_ylabel('权重值')
ax.set_title('不同 λ 下的权重分布(d=15)')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
plt.suptitle('L2 正则化效果:λ 越大 → 权重越小 → 曲线越平滑', fontsize=16, y=1.02)
plt.tight_layout()
plt.show()
L1 与 L2 的几何——圆球 vs 菱形
Ridge 用 $\lambda\|\mathbf{w}\|_2^2$,Lasso 用 $\lambda\|\mathbf{w}\|_1$。为什么 Lasso 能让一些权重恰好为零,而 Ridge 不行?
PRML §3.1.4 给出了一种优雅的等价形式:把正则化损失写成带约束的优化问题。
第一步:两种等价的写法。 最小化 $L_{\text{ridge}} = \text{MSE} + \lambda\|\mathbf{w}\|_2^2$,等价于在 $\|\mathbf{w}\|_2 \le t$ 的约束下最小化 MSE。$\lambda$ 大 $\leftrightarrow$ $t$ 小,一回事。Lasso 同理:
| 罚项形式 | 约束形式 | |
|---|---|---|
| Ridge (L2) | $\min_{\mathbf{w}}\ \text{MSE} + \lambda\|\mathbf{w}\|_2^2$ | $\min_{\mathbf{w}}\ \text{MSE},\ \text{s.t.}\ \|\mathbf{w}\|_2 \le t$ |
| Lasso (L1) | $\min_{\mathbf{w}}\ \text{MSE} + \lambda\|\mathbf{w}\|_1$ | $\min_{\mathbf{w}}\ \text{MSE},\ \text{s.t.}\ \|\mathbf{w}\|_1 \le t$ |
第二步:看约束区域的形状。 在二维参数空间 $(w_1, w_2)$ 里——
- $\|\mathbf{w}\|_2 = \sqrt{w_1^2 + w_2^2} \le t$ 是一个圆——边界光滑,没有棱角
- $\|\mathbf{w}\|_1 = |w_1| + |w_2| \le t$ 是一个菱形——四个尖角正好戳在坐标轴上
第三步:MSE 等高线碰到哪? MSE 的等高线是椭圆(因为二次型)。最优解就是最小的椭圆碰到约束区域的第一个点。
- 圆:椭圆有可能碰到圆弧上的任意一点。除非巧合,一般两个坐标都不为零。
- 菱形:椭圆的轴如果不和菱形的边完全平行,它大概率先碰到尖角——也就是坐标轴上,某个 $w_j = 0$。
这就是 Lasso 自带特征选择的几何原因。不是魔法,是菱形的尖角恰好落在「某个权重为零」的位置上。PRML Fig 3.3 和 Fig 3.4 是理解这个的关键图。
第四步:贝叶斯解释。 约束区域的形状对应先验分布的等概率轮廓:
- L2 → 高斯先验 $p(w) \propto e^{-w^2/2}$:原点处平滑,概率质量均匀扩散 → 权重收缩但不归零
- L1 → 拉普拉斯先验 $p(w) \propto e^{-|w|}$:原点处有尖峰,概率质量集中在零附近 → 很多权重正好为零
下面画出来——二维参数空间里,MSE 椭圆同时碰到圆和菱形,看最优解分别落在哪里。
# L1 vs L2 几何可视化:约束区域 + MSE 等高线
# 在二维参数空间中展示 Ridge (圆) 和 Lasso (菱形) 的区别
# 用 sin 数据的 d=1 线性模型(只有 w 和 b 两个参数),画 MSE 等高线
# 然后叠加 L1/L2 约束区域
# 在 (w, b) 网格上计算 MSE
w_grid = np.linspace(-2, 6, 200)
b_grid = np.linspace(-2, 8, 200)
W, B = np.meshgrid(w_grid, b_grid)
MSE_grid = np.zeros_like(W)
for i in range(len(w_grid)):
for j in range(len(b_grid)):
MSE_grid[j, i] = np.mean((W[j, i] * X2 + B[j, i] - y2) ** 2)
# MSE 最小值(无正则化时的最优解)
# 用正规方程求解
Phi_simple = np.column_stack([X2, np.ones_like(X2)]) # [x, 1],注意顺序:w 在 x 列,b 在 1 列
w_ols_simple = np.linalg.solve(Phi_simple.T @ Phi_simple, Phi_simple.T @ y2)
w_opt, b_opt = w_ols_simple[0], w_ols_simple[1]
fig, axes = plt.subplots(1, 2, figsize=(16, 7))
# 公共的 MSE 等高线级别
levels = np.geomspace(MSE_grid.min() + 0.01, MSE_grid.max() * 0.3, 12)
for ax, penalty, title, shape_label in [
(axes[0], 'l2', 'Ridge (L2):圆约束', '圆 ∥w∥₂ ≤ t'),
(axes[1], 'l1', 'Lasso (L1):菱形约束', '菱形 ∥w∥₁ ≤ t'),
]:
# MSE 等高线
cs = ax.contour(W, B, MSE_grid, levels=levels, colors='steelblue', alpha=0.6, linewidths=1)
ax.clabel(cs, inline=True, fontsize=7, fmt='%.1f')
# 无约束最优解
ax.plot(w_opt, b_opt, 'k*', markersize=15, label=f'无约束最优 ({w_opt:.2f}, {b_opt:.2f})')
# 约束区域(三个不同 t)
ts = [1.5, 3.0, 5.0]
colors = ['#ff6b6b', '#ffa500', '#2ecc71']
for t, c in zip(ts, colors):
theta = np.linspace(0, 2*np.pi, 300)
if penalty == 'l2':
# L2: w² + b² ≤ t² 但注意我们中心在 (0, 0)
x_bound = w_opt + t * np.cos(theta)
y_bound = b_opt + t * np.sin(theta)
else:
# L1: |w-w_opt| + |b-b_opt| ≤ t → 菱形
# 菱形顶点: right, top, left, bottom
vertices = np.array([
[w_opt + t, b_opt],
[w_opt, b_opt + t],
[w_opt - t, b_opt],
[w_opt, b_opt - t],
[w_opt + t, b_opt],
])
x_bound, y_bound = vertices[:, 0], vertices[:, 1]
ax.fill(x_bound, y_bound, alpha=0.12, color=c, edgecolor=c, linewidth=2,
label=f't={t}')
ax.set_xlabel('w'); ax.set_ylabel('b')
ax.set_title(title)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
ax.set_xlim(w_opt - 7, w_opt + 7)
ax.set_ylim(b_opt - 7, b_opt + 7)
plt.suptitle('L1 vs L2 正则化的几何直觉:MSE 等高线 + 约束区域', fontsize=16, y=1.02)
plt.tight_layout()
plt.show()
print("关键观察:")
print(f" 无约束 MSE 最优解: w={w_opt:.2f}, b={b_opt:.2f}")
print(f" Ridge (L2): 约束是圆,MSE 等高线碰到圆弧 → 两个参数都收缩,但都不为零")
print(f" Lasso (L1): 约束是菱形,MSE 等高线碰到尖角 → 某些参数恰好为零")
print(f"\n 这在 PRML Fig 3.3 和 Fig 3.4 有详细说明。")
关键观察:
无约束 MSE 最优解: w=0.32, b=-0.06
Ridge (L2): 约束是圆,MSE 等高线碰到圆弧 → 两个参数都收缩,但都不为零
Lasso (L1): 约束是菱形,MSE 等高线碰到尖角 → 某些参数恰好为零
这在 PRML Fig 3.3 和 Fig 3.4 有详细说明。
偏差-方差分解
PRML §3.2 回答了为什么复杂模型在训练集上无敌、在新数据上拉胯。答案不是玄学——可以精确地拆成三项。
三个错误来源
假设真实函数是 $h(x)$。我们采样了一组数据集 $\mathcal{D}$,在上面训练出模型 $f(x; \mathcal{D})$。对新点 $x_0$,期望预测误差可以分解:
$$\underbrace{\mathbb{E}_{\mathcal{D}}\big[(f(x_0;\mathcal{D}) - h(x_0))^2\big]}_{\text{期望预测误差}} = \underbrace{(\mathbb{E}[f] - h)^2}_{\text{偏差}^2} + \underbrace{\mathbb{E}[(f - \mathbb{E}[f])^2]}_{\text{方差}} + \underbrace{\sigma^2}_{\text{不可约噪声}}$$- 偏差²:即使有无穷多组数据、取平均预测,它离真实值有多远?→ 模型的表达能力
- 方差:换一组训练数据,预测结果变多少?→ 模型对数据的敏感度
- 噪声:真实数据自带的随机性,谁也消除不了
高偏差 = 脑子笨(简单模型,连训练集都拟合不好)。高方差 = 情绪不稳定(复杂模型,换一组数据就换一套答案)。你要的是聪明又淡定——偏差和方差的平衡。
实验:用 100 组数据验证这个分解
对 $\sin(x)$ 加噪声,反复采样 100 组不同的训练集。每组上训练不同复杂度的多项式。看所有模型的平均预测和波动。
# 偏差-方差分解实验:从同一真实函数重复采样 → 训练 → 统计预测分布
def bias_variance_experiment(true_func, n_datasets=100, n_train=30, noise_std=0.15,
degrees=[1, 3, 5, 9, 15], x_range=(-3, 3)):
"""对每个多项式次数 d,在 n_datasets 组数据上训练,计算 bias² 和 variance"""
np.random.seed(42)
x_test = np.linspace(x_range[0], x_range[1], 200)
results = {}
for d in degrees:
predictions = np.zeros((n_datasets, len(x_test)))
for ds in range(n_datasets):
# 采样训练集
X_train = np.random.uniform(*x_range, n_train)
y_train = true_func(X_train) + np.random.normal(0, noise_std, n_train)
# 训练
Phi_train = poly_features(X_train, d)
w, _, mean, std = poly_gradient_descent(Phi_train, y_train,
lr=0.1, epochs=2000, standardize_features=(d > 1), verbose=False)
# 预测
y_pred = poly_predict(x_test, w, mean, std)
predictions[ds] = y_pred
# 统计
mean_pred = predictions.mean(axis=0)
bias_sq = (mean_pred - true_func(x_test)) ** 2
variance = predictions.var(axis=0)
avg_bias_sq = bias_sq.mean()
avg_variance = variance.mean()
results[d] = {
'predictions': predictions,
'mean_pred': mean_pred,
'bias_sq': bias_sq,
'variance': variance,
'avg_bias_sq': avg_bias_sq,
'avg_variance': avg_variance,
}
return results, x_test
# 跑实验
true_func = np.sin
bv_results, x_bv = bias_variance_experiment(true_func, n_datasets=100, n_train=30)
degrees_bv = list(bv_results.keys())
# 打印各 d 的 bias² 和 variance
print(f"{'d':>3} {'bias²':>10} {'variance':>10} {'bias²+variance':>15}")
print("-" * 42)
for d in degrees_bv:
b2 = bv_results[d]['avg_bias_sq']
v = bv_results[d]['avg_variance']
print(f"{d:>3} {b2:>10.5f} {v:>10.5f} {b2+v:>15.5f}")
/home/pdch/workspace/pdchlog/.venv/lib/python3.12/site-packages/numpy/_core/_methods.py:132: RuntimeWarning: overflow encountered in reduce
ret = umr_sum(arr, axis, dtype, out, keepdims, where=where)
/tmp/ipykernel_769530/1149040970.py:31: RuntimeWarning: overflow encountered in square
loss_history.append(np.mean((Phi @ w - y) ** 2))
/tmp/ipykernel_769530/1149040970.py:31: RuntimeWarning: overflow encountered in matmul
loss_history.append(np.mean((Phi @ w - y) ** 2))
/tmp/ipykernel_769530/1149040970.py:28: RuntimeWarning: overflow encountered in matmul
error = Phi @ w - y
/tmp/ipykernel_769530/1149040970.py:31: RuntimeWarning: invalid value encountered in matmul
loss_history.append(np.mean((Phi @ w - y) ** 2))
/tmp/ipykernel_769530/1149040970.py:28: RuntimeWarning: invalid value encountered in matmul
error = Phi @ w - y
d bias² variance bias²+variance
------------------------------------------
1 0.16854 0.01466 0.18320
3 0.00337 0.00434 0.00771
5 0.00136 0.01064 0.01200
9 0.00208 0.03634 0.03842
15 nan nan nan
# 可视化:偏差-方差分解
fig, axes = plt.subplots(2, 3, figsize=(18, 11))
axes = axes.flatten()
# 前 5 个子图:每个 d 的 100 条拟合曲线 + 平均 + 真实函数
for idx, d in enumerate(degrees_bv):
ax = axes[idx]
preds = bv_results[d]['predictions']
mean_pred = bv_results[d]['mean_pred']
# 画前 20 条拟合曲线(半透明)
for p in preds[:20]:
ax.plot(x_bv, p, 'b-', alpha=0.08, linewidth=0.8)
# 平均预测
ax.plot(x_bv, mean_pred, 'g-', linewidth=2.5, label=f'平均预测 (d={d})')
# 真实函数
ax.plot(x_bv, true_func(x_bv), 'r--', linewidth=2, label='真实 sin(x)')
ax.set_xlim(-3, 3); ax.set_ylim(-2, 2)
b2 = bv_results[d]['avg_bias_sq']
v = bv_results[d]['avg_variance']
ax.set_title(f'd={d} | bias²={b2:.4f}, var={v:.4f}')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
# 第 6 个子图:bias² vs variance 权衡曲线
ax = axes[-1]
bias_list = [bv_results[d]['avg_bias_sq'] for d in degrees_bv]
var_list = [bv_results[d]['avg_variance'] for d in degrees_bv]
total_list = [b + v for b, v in zip(bias_list, var_list)]
x_pos = range(len(degrees_bv))
w = 0.25
ax.bar([p - w for p in x_pos], bias_list, w, label='bias²', color='steelblue')
ax.bar(x_pos, var_list, w, label='variance', color='coral')
ax.bar([p + w for p in x_pos], total_list, w, label='bias²+var', color='gray', alpha=0.5)
ax.set_xticks(x_pos)
ax.set_xticklabels([f'd={d}' for d in degrees_bv])
ax.set_xlabel('多项式次数'); ax.set_ylabel('误差')
ax.set_title('偏差-方差权衡:d 越大 bias↓ 但 variance↑')
ax.legend(); ax.grid(True, alpha=0.3)
plt.suptitle('偏差-方差分解:100 组数据的拟合分布', fontsize=16, y=1.01)
plt.tight_layout()
plt.show()
print("\nd=1: 高偏差低方差 — 所有拟合几乎一样,但都偏离真实函数(欠拟合)")
print("d=15: 低偏差高方差 — 每条拟合都贴近自己的训练点,但彼此差异巨大(过拟合)")
print("d=3: 最佳平衡点")
d=1: 高偏差低方差 — 所有拟合几乎一样,但都偏离真实函数(欠拟合)
d=15: 低偏差高方差 — 每条拟合都贴近自己的训练点,但彼此差异巨大(过拟合)
d=3: 最佳平衡点
实践中怎么做:Train / Validation Split
上面用 100 组数据展示了 bias-variance 的理论分解。但现实中你只有一组数据——没法做那个实验。
实际的做法:把手里这组数据随机拆成训练集(80%)和验证集(20%)。训练集用来更新参数,验证集用来监控过拟合。train loss 下降但 val loss 上升 → 过拟合。
# 生成更多数据用于 train/val split
N3 = 150
X3 = np.random.uniform(-3, 3, N3)
y3 = np.sin(X3) + np.random.normal(0, 0.15, N3)
# 手写 train/val split(80/20)
def train_val_split(X, y, val_ratio=0.2, seed=42):
np.random.seed(seed)
idx = np.random.permutation(len(X))
split = int(len(X) * (1 - val_ratio))
train_idx, val_idx = idx[:split], idx[split:]
return X[train_idx], X[val_idx], y[train_idx], y[val_idx]
X_train, X_val, y_train, y_val = train_val_split(X3, y3)
print(f"训练集: {len(X_train)} 样本, 验证集: {len(X_val)} 样本")
# 可视化划分
plt.figure(figsize=(8, 5))
plt.scatter(X_train, y_train, alpha=0.6, label=f'训练集 ({len(X_train)})', c='steelblue')
plt.scatter(X_val, y_val, alpha=0.8, label=f'验证集 ({len(X_val)})', c='coral', marker='s', s=60)
plt.plot(x_smooth, np.sin(x_smooth), 'k--', lw=1.5, label='真实 sin(x)')
plt.xlabel('x'); plt.ylabel('y')
plt.legend(); plt.title('Train / Validation Split')
plt.grid(True, alpha=0.3)
plt.show()
训练集: 120 样本, 验证集: 30 样本
两条 loss 曲线的含义
同时看 train loss 和 val loss。过拟合的信号:train loss 一路降,val loss 在某个点之后扭头向上。
$d=9$ 和 $d=15$ 都有这个现象。$d=15$ 的 val loss 谷底之后飙升——太多参数在「背答案」。
def train_with_val(Phi_train, y_train, Phi_val, y_val, lr, epochs, lam=0.0, standardize_features=True):
"""训练并记录 train/val 损失(可选标准化)"""
if standardize_features and Phi_train.shape[1] > 1:
mean = np.mean(Phi_train[:, 1:], axis=0)
std = np.std(Phi_train[:, 1:], axis=0)
std[std == 0] = 1.0
Phi_train = Phi_train.copy()
Phi_train[:, 1:] = (Phi_train[:, 1:] - mean) / std
Phi_val = Phi_val.copy()
Phi_val[:, 1:] = (Phi_val[:, 1:] - mean) / std
N, D = Phi_train.shape
w = np.zeros(D)
train_losses, val_losses = [], []
for epoch in range(epochs):
# 梯度计算(仅在训练集上)
error = Phi_train @ w - y_train
grad_mse = (2 / N) * Phi_train.T @ error
grad_l2 = 2 * lam * w
w -= lr * (grad_mse + grad_l2)
# 记录损失
train_loss = np.mean((Phi_train @ w - y_train) ** 2) + lam * np.sum(w ** 2)
val_loss = np.mean((Phi_val @ w - y_val) ** 2)
train_losses.append(train_loss)
val_losses.append(val_loss)
return w, np.array(train_losses), np.array(val_losses)
# 对不同多项式次数,画 train/val 两条 loss 曲线
d_list = [1, 3, 5, 9, 15]
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()
for idx, d in enumerate(d_list):
Phi_train = poly_features(X_train, d)
Phi_val = poly_features(X_val, d)
w, train_loss, val_loss = train_with_val(Phi_train, y_train, Phi_val, y_val,
lr=0.1, epochs=3000)
ax = axes[idx]
ax.plot(train_loss, 'b-', alpha=0.7, label='Train Loss')
ax.plot(val_loss, 'r-', alpha=0.7, label='Val Loss')
ax.set_xlabel('Epoch'); ax.set_ylabel('MSE')
ax.set_title(f'd = {d} | 最终: train={train_loss[-1]:.4f}, val={val_loss[-1]:.4f}')
ax.legend(); ax.grid(True, alpha=0.3)
ax.set_yscale('log')
# 标记过拟合物始点(val loss 最小值)
best_epoch = np.argmin(val_loss)
ax.axvline(x=best_epoch, color='gray', linestyle=':', alpha=0.5)
if best_epoch < len(val_loss) * 0.8:
ax.annotate(f'最佳 epoch={best_epoch}', xy=(best_epoch, val_loss[best_epoch]),
xytext=(best_epoch + 300, val_loss[best_epoch] * 2),
arrowprops=dict(arrowstyle='->', color='gray'), fontsize=9)
# 最后一个子图:各次数的最终 val loss 对比
ax = axes[-1]
final_val_losses = []
for d in d_list:
Phi_train = poly_features(X_train, d)
Phi_val = poly_features(X_val, d)
w, _, val_loss = train_with_val(Phi_train, y_train, Phi_val, y_val,
lr=0.1, epochs=3000)
final_val_losses.append(val_loss[-1])
ax.bar(d_list, final_val_losses, color=['steelblue']*3 + ['coral', 'firebrick'])
ax.set_xlabel('多项式次数 d'); ax.set_ylabel('最终 Val Loss')
ax.set_title('Val Loss vs 多项式次数')
ax.grid(True, alpha=0.3)
plt.suptitle('Train/Val Loss 曲线:过拟合的信号', fontsize=16, y=1.02)
plt.tight_layout()
plt.show()
学习率:梯度下降的步长
学习率太大 → loss 震荡甚至发散;太小 → 收敛太慢。在 loss 曲面上,步长决定了你是蚂蚁还是袋鼠。
下面六组实验,直观感受。
# 用 Week1 的线性数据,尝试不同学习率
learning_rates = [0.001, 0.01, 0.05, 0.1, 0.5, 1.0]
d_poly = 1 # 简单直线,只看学习率的影响
Phi_lr = poly_features(X, d_poly)
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()
for idx, lr_val in enumerate(learning_rates):
w, train_loss, val_loss = train_with_val(
Phi_lr, y, Phi_lr, y, # 用全部数据,只观察收敛行为
lr=lr_val, epochs=200
)
ax = axes[idx]
ax.plot(train_loss, 'b-', linewidth=1.5)
ax.set_xlabel('Epoch'); ax.set_ylabel('MSE Loss')
final_loss = train_loss[-1]
if final_loss > 1e6:
status = '[发散!]'
color = 'red'
elif final_loss > train_loss[0]:
status = '[震荡]'
color = 'orange'
elif train_loss[-1] / train_loss[0] > 0.1:
status = '[太慢]'
color = 'goldenrod'
else:
status = '[收敛良好]'
color = 'green'
ax.set_title(f'lr = {lr_val} {status}\n最终 loss = {final_loss:.4f}', color=color)
ax.set_yscale('log')
ax.grid(True, alpha=0.3)
plt.suptitle('学习率对收敛的影响', fontsize=16, y=1.02)
plt.tight_layout()
plt.show()
print("""
结论:
- lr 太小(0.001):收敛极慢,200 轮还没到底
- lr 适中(0.01~0.1):稳定收敛
- lr 太大(0.5~1.0):震荡或直接发散
引申 → 动量法、Adam 的动机(阶段三会用到):
动量法在参数更新时"记住"之前的梯度方向,像滚下坡的球,
可以加速收敛并减少震荡。
""")
结论:
- lr 太小(0.001):收敛极慢,200 轮还没到底
- lr 适中(0.01~0.1):稳定收敛
- lr 太大(0.5~1.0):震荡或直接发散
引申 → 动量法、Adam 的动机(阶段三会用到):
动量法在参数更新时"记住"之前的梯度方向,像滚下坡的球,
可以加速收敛并减少震荡。
贝叶斯线性回归
PRML §3.3 是全书最重要的范式转换之一。
从「找最好的 $\mathbf{w}$」到「$\mathbf{w}$ 的分布是什么」
频率派(§3.1):找一个点估计 $\mathbf{w}_{\text{MLE}}$,最大化似然 $p(\mathcal{D} \mid \mathbf{w})$。
贝叶斯派(§3.3):没看到数据之前,我先对 $\mathbf{w}$ 有一个先验信念——比如「参数应该比较小,集中在 0 附近」。看到数据后,用贝叶斯定理更新信念:
$$p(\mathbf{w} \mid \mathcal{D}) \propto \underbrace{p(\mathcal{D} \mid \mathbf{w})}_{\text{似然(和频率派一样)}} \cdot \underbrace{p(\mathbf{w})}_{\text{先验(这是新增的)}}$$先验是高斯 $p(\mathbf{w}) = \mathcal{N}(\mathbf{w} \mid 0, \alpha^{-1}I)$,似然也是高斯($y = \mathbf{w}^\top\phi(x) + \varepsilon,\ \varepsilon \sim \mathcal{N}(0,\sigma^2)$),那后验也是高斯。取负对数:
$$-\log p(\mathbf{w} \mid \mathcal{D}) = \frac{1}{2\sigma^2}\sum_{i=1}^N (y_i - \mathbf{w}^\top\phi(x_i))^2 + \frac{\alpha}{2}\|\mathbf{w}\|_2^2 + \text{常数}$$最大化后验(MAP)$\Leftrightarrow$ 最小化 MSE $+ \frac{\alpha\sigma^2}{2}\|\mathbf{w}\|_2^2$。
令 $\lambda = \alpha\sigma^2$,这就是 Ridge。「正则化 = 先验」 — 你在 §3.1.4 手写的 Ridge 梯度下降,本质上就是在求贝叶斯后验的 MAP 解。L2 = 高斯先验(收缩但不归零),L1 = 拉普拉斯先验(稀疏)。
但 MAP 只是贝叶斯的半成品
MAP 给的是一个点估计——找到后验分布的最高点。但贝叶斯的真正力量在于:你手里是整个后验分布,不是只有一个点。
有了分布,你可以回答频率派答不了的问题:预测有多不确定? 这就引出了 §3.3.2 的预测分布。
预测分布——频率派给不了的东西
MAP 还是只给了一个点。贝叶斯的真正威力:预测时带着不确定性。
对一个新的输入 $x_*$,我们不给一个数,而是给一个分布:
$$p(y_* \mid x_*, \mathcal{D}) = \int \underbrace{p(y_* \mid x_*, \mathbf{w})}_{\text{给定 w 的预测}} \cdot \underbrace{p(\mathbf{w} \mid \mathcal{D})}_{\text{w 的后验分布}}\, d\mathbf{w}$$因为似然和后验都是高斯,这个积分有闭式解——结果还是高斯:
$$p(y_* \mid x_*, \mathcal{D}) = \mathcal{N}\big(y_* \mid \mathbf{m}_N^\top \phi(x_*),\; \sigma_N^2(x_*)\big)$$其中预测方差有两部分:
$$\sigma_N^2(x_*) = \underbrace{\frac{1}{\beta}}_{\text{噪声}} + \underbrace{\phi(x_*)^\top \mathbf{S}_N \phi(x_*)}_{\text{参数不确定性}}$$- 第一项:数据自带的噪声($\beta = 1/\sigma^2$ 是噪声精度)
- 第二项:我们对 $\mathbf{w}$ 有多不确定?→ $\mathbf{S}_N$ 是后验协方差矩阵
关键点:$\phi(x_*)^\top \mathbf{S}_N \phi(x_*)$ 在数据密集的地方小、在数据稀疏的地方大。也就是说——模型在自己没见过的区域自动变保守。
频率派的预测:$\hat{y}_* = \mathbf{w}_{\text{MLE}}^\top \phi(x_*)$。只有一个数,没有「我不太确定」这个维度。贝叶斯的预测:均值和方差都有。这是质的区别。
下面手写贝叶斯线性回归(含后验协方差的计算 + 预测分布的均值和方差),画置信带。
# 贝叶斯线性回归(完整后验 + 预测分布)
# 用 d=3 多项式 + sin 数据演示
def bayesian_linear_regression(Phi, y, alpha=1.0, beta=25.0):
"""
贝叶斯线性回归(PRML §3.3)
alpha: 先验精度(p(w) = N(0, alpha^{-1} I))
beta: 噪声精度(p(y|x,w) = N(w^T phi(x), beta^{-1}))
返回: 后验均值 m_N, 后验协方差 S_N
"""
N, D = Phi.shape
# 后验协方差: S_N^{-1} = alpha*I + beta*Phi^T*Phi
S_N_inv = alpha * np.eye(D) + beta * Phi.T @ Phi
S_N = np.linalg.inv(S_N_inv)
# 后验均值: m_N = beta * S_N * Phi^T * y
m_N = beta * S_N @ Phi.T @ y
return m_N, S_N
def bayesian_predict(Phi_test, m_N, S_N, beta=25.0):
"""
贝叶斯预测分布(PRML §3.3.2)
返回: 预测均值, 预测方差(含噪声项 + 参数不确定性项)
"""
mean = Phi_test @ m_N
# 方差 = 1/beta (噪声) + phi^T S_N phi (参数不确定性)
var = 1.0 / beta + np.sum(Phi_test @ S_N * Phi_test, axis=1)
return mean, var
# 用 sin 数据,d=3 多项式
d_bayes = 3
Phi_bayes = poly_features(X2, d_bayes)
# 标准化(和之前一致)
Phi_bayes_scaled, mean_bayes, std_bayes = standardize(Phi_bayes)
# 贝叶斯推断
alpha = 1.0 # 先验精度:先验越强(alpha 越大),权重越被拉向 0
beta = 25.0 # 噪声精度:约等于 1/σ²,σ≈0.2 → beta≈25
m_N, S_N = bayesian_linear_regression(Phi_bayes_scaled, y2, alpha=alpha, beta=beta)
print(f"后验均值 m_N (前 4 维): {m_N[:4]}")
print(f"后验协方差 S_N 的迹: {np.trace(S_N):.4f}")
# 预测
X_bayes_test = np.linspace(-3.5, 3.5, 200)
Phi_bayes_test = poly_features(X_bayes_test, d_bayes)
Phi_bayes_test_scaled = Phi_bayes_test.copy()
Phi_bayes_test_scaled[:, 1:] = (Phi_bayes_test[:, 1:] - mean_bayes) / std_bayes
y_mean, y_var = bayesian_predict(Phi_bayes_test_scaled, m_N, S_N, beta=beta)
y_std = np.sqrt(y_var)
# 可视化:预测均值 + 2σ 置信带
plt.figure(figsize=(10, 6))
plt.scatter(X2, y2, alpha=0.5, s=20, label='训练数据')
plt.plot(X_bayes_test, np.sin(X_bayes_test), 'r--', lw=2, label='真实 sin(x)')
plt.plot(X_bayes_test, y_mean, 'g-', lw=2, label='贝叶斯预测均值')
plt.fill_between(X_bayes_test, y_mean - 2*y_std, y_mean + 2*y_std,
alpha=0.3, color='green', label='±2σ 置信带')
plt.xlabel('x'); plt.ylabel('y')
plt.title('贝叶斯线性回归 (d=3):预测分布带置信带')
plt.legend(); plt.grid(True, alpha=0.3)
plt.show()
print(f"\n置信带宽度的统计:")
print(f" 最小 σ = {y_std.min():.4f}(数据密集区,最确定)")
print(f" 最大 σ = {y_std.max():.4f}(边缘区域,不确定度上升)")
后验均值 m_N (前 4 维): [-0.07490643 1.51231841 -0.01671152 -1.02110908]
后验协方差 S_N 的迹: 0.0062
置信带宽度的统计:
最小 σ = 0.2023(数据密集区,最确定)
最大 σ = 0.2410(边缘区域,不确定度上升)
# 对比不同先验强度 alpha 对预测不确定性的影响
# alpha 越大 → 先验越强 → 后验更收缩 → 预测不确定性更小(但也可能更偏)
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
alphas = [0.1, 1.0, 10.0]
for idx, alpha_val in enumerate(alphas):
m_N_a, S_N_a = bayesian_linear_regression(Phi_bayes_scaled, y2, alpha=alpha_val, beta=beta)
y_mean_a, y_var_a = bayesian_predict(Phi_bayes_test_scaled, m_N_a, S_N_a, beta=beta)
y_std_a = np.sqrt(y_var_a)
ax = axes[idx]
ax.scatter(X2, y2, alpha=0.4, s=15)
ax.plot(X_bayes_test, np.sin(X_bayes_test), 'r--', lw=1.5, label='真实')
ax.plot(X_bayes_test, y_mean_a, 'g-', lw=2, label='预测均值')
ax.fill_between(X_bayes_test, y_mean_a - 2*y_std_a, y_mean_a + 2*y_std_a,
alpha=0.3, color='green')
ax.set_xlim(-3.5, 3.5); ax.set_ylim(-2, 2)
ax.set_title(f'α = {alpha_val} ({"弱先验,数据主导" if alpha_val < 1 else "强先验,更保守"})')
ax.legend(fontsize=8); ax.grid(True, alpha=0.3)
plt.suptitle('先验精度 α 对贝叶斯预测分布的影响', fontsize=14, y=1.02)
plt.tight_layout()
plt.show()
print("α=0.1: 弱先验 → 置信带宽(因为让数据说话,不确定度高)")
print("α=1.0: 适中")
print("α=10: 强先验 → 置信带窄(因为先验很确定 w≈0,但可能偏)")
print("\nPRML §3.5(证据近似)讲的就是怎么从数据自动选 α 和 β。")
α=0.1: 弱先验 → 置信带宽(因为让数据说话,不确定度高)
α=1.0: 适中
α=10: 强先验 → 置信带窄(因为先验很确定 w≈0,但可能偏)
PRML §3.5(证据近似)讲的就是怎么从数据自动选 α 和 β。
贝叶斯模型比较
PRML §3.4 回答了一个自然的问题:d=3 和 d=9 的多项式,哪个更好?
频率派的答案:在验证集上比 loss。问题是——你得留出一部分数据不做训练,数据少的时候心疼。
贝叶斯的答案:直接算模型证据(marginal likelihood):
$$p(\mathcal{D} \mid \mathcal{M}) = \int p(\mathcal{D} \mid \mathbf{w}, \mathcal{M})\, p(\mathbf{w} \mid \mathcal{M})\, d\mathbf{w}$$这个积分自动惩罚了过于复杂的模型——因为复杂模型的先验分布更「散」(覆盖了更多可能的参数值),而数据只支持其中一小部分,所以证据反而低。这就是奥卡姆剃刀的概率版本。
你不需要显式加正则化项。贝叶斯模型比较自动在模型复杂度和数据拟合度之间做权衡。PRML Fig 3.13 是理解这个的关键图——复杂模型的证据分布更宽但峰值更低。
固定基函数的局限
PRML §3.6 诚实地说出了这种方法的边界:
维数灾难:输入维度 $D$ 变大时,多项式基函数的数量 $O(D^d)$ 爆炸。$D=100$、$d=3$ 就是 17 万个基函数——矩阵求逆直接去世。
基函数要手动选:多项式还是高斯核?选哪个、参数怎么设?全靠人。这跟「让机器自己学」的理想差了一截。
数据点的全局影响:每个训练点对所有预测都有同等影响。局部数据可能被远处理论上不相关的点干扰。
这些局限正好引出第四章(分类)和后续章节(核方法、神经网络)。神经网络本质上就是在学基函数本身——$\phi(x)$ 不再是手工设计的,而是通过隐藏层从数据中学出来的。那才是真正的「深度」学习。
总结:一张表装下第三章
| 章节 | 核心结论 | 动手做了什么 |
|---|---|---|
| §3.1.1 最大似然 | 高斯噪声假设下 MLE = MSE | 从似然函数推导出 MSE + 手写梯度下降 |
| §3.1.2 最小二乘几何 | 预测是 $\mathbf{y}$ 在 $\Phi$ 列空间的正交投影 | 3D 可视化:投影 + 残差 ⊥ 列空间 |
| §3.1.3 序贯学习 | SGD 每次看一个点,吵但省计算 | 手写 SGD,对比批量 GD 的收敛轨迹 |
| §3.1 基函数 | $\phi(x)$ 升维 → 直线变曲线,但关于 $\mathbf{w}$ 仍线性 | 多项式拟合 sin(x),复现 PRML Fig 3.1 |
| §3.1.4 正则化 | $\lambda\|\mathbf{w}\|^2$ 限制权重 → 抑制过拟合 | 手写 Ridge 梯度下降 + 梯度分解可视化 |
| §3.2 偏差-方差 | 期望误差 = bias² + variance + noise | 100 组数据集实验,画拟合分布 |
| §3.3 贝叶斯回归 | 正则化 = 先验;MAP = Ridge | 从先验→后验→MAP 的完整推导 |
| §3.3.2 预测分布 | 贝叶斯预测带置信区间,数据稀疏处自动变宽 | 手写后验协方差 + 预测方差 + 置信带 |
| §3.4 模型比较 | 贝叶斯证据自动惩罚复杂模型 | (概念推导) |
| §3.6 局限 | 维数灾难、基函数靠人选 | 引出神经网络动机 |
三个贯穿全章的认知
- 损失函数不是随便选的——每种损失对应一种噪声假设。MSE ↔ 高斯,MAE ↔ 拉普拉斯。
- 「正则化 = 先验」——频率派的 Ridge 和贝叶斯派的 MAP 是同一种计算。L2 ↔ 高斯先验,L1 ↔ 拉普拉斯先验。
- 贝叶斯不只是「频率派 + 先验」——有了后验分布,你可以给出预测的不确定性。这是质的区别。


说些什么吧!