[HITML] 机器学习Lab1实验:从梯度下降到正则化实战解析
1. 实验准备:从“猜数字”到“猜函数”
大家好,我是你们的老朋友,一个在AI和机器学习领域摸爬滚打了十多年的工程师。今天,我想和大家聊聊一个听起来有点“学术”,但实际上非常有趣且核心的机器学习实验——多项式曲线拟合。别被“梯度下降”、“正则化”这些词吓到,咱们今天就用最“小白”的方式来拆解它。
想象一下,你面前有一张白纸,上面散落着十几个点,这些点大致构成了一条波浪形的曲线。你的任务是:找出一支“魔法笔”,画出一条光滑的曲线,让它尽可能地穿过所有这些点,并且,当你在空白处再点一个新点时,这条曲线也能很好地预测这个新点的位置。这就是我们这次实验要解决的核心问题:如何根据有限的数据点,找到一个最合适的函数(模型)来描述它们背后的规律。
在机器学习里,我们管这个叫“回归”问题。而“多项式拟合”,就是我们选择“魔法笔”的一种方式。这支笔能画出不同复杂度的曲线,从一条简单的直线(1次多项式),到可以扭来扭去的复杂曲线(比如9次多项式)。复杂度越高,笔就越“灵活”,越能精确地穿过每一个已知的数据点。但这真的是好事吗?未必。如果笔太“灵活”,为了穿过所有旧点而画得“张牙舞爪”,它对新点的预测往往会错得离谱。这就是我们常说的 “过拟合”。
所以,这个实验的目的,就是让你亲手操作这支“魔法笔”,体验从“欠拟合”(笔太笨,画不出波浪)到“完美拟合”(笔刚好画出波浪),再到“过拟合”(笔乱扭)的全过程。更重要的是,我们要学会如何给这支“过于灵活”的笔加上“束缚”,让它既保持一定的灵活性,又不至于失控。这个“束缚”,就是正则化。而找到那支笔最优参数的过程,我们会用两种经典方法来实现:梯度下降和共轭梯度法。
我当年第一次做这个实验时,看着自己写的代码画出的曲线从一条平淡的直线,变成一条疯狂震荡的线,最后又通过调整参数让它回归平滑,那种“哦!原来如此!”的顿悟感,至今记忆犹新。接下来,我就带你一步步复现这个过程,并分享我踩过的坑和总结的经验。
2. 搭建实验舞台:生成你的第一个数据集
理论说再多,不如动手做。我们实验的第一步,就是创造一个可以供我们“折腾”的数据集。我们选择的目标函数是经典的 $sin(2\pi x)$,它是一个在0到1之间完成一个完整周期的漂亮正弦波。但在真实世界中,我们采集到的数据总是带有噪声的,比如测量误差、环境干扰等。
所以,我们的数据生成函数要完成两件事:一是生成纯净的正弦波点,二是为这些点加上一点“生活气息”——高斯噪声。下面是我在实验中使用的代码,我会逐行解释:
import numpy as np
import matplotlib.pyplot as plt
def generate_data(poly_order, sample_size, mu=0, sigma=0.05, begin=0, end=1):
"""
生成带噪声的正弦波训练数据。
:param poly_order: 多项式阶数,决定了我们模型的复杂度
:param sample_size: 训练样本数量,数据点的多少
:param mu: 高斯噪声的均值,通常为0
:param sigma: 高斯噪声的标准差,控制噪声大小
:param begin, end: 数据点的x轴范围
:return: 设计矩阵X,标签向量Y,以及原始的x和y(用于画图)
"""
# 1. 在[begin, end)区间内,生成sample_size个等间隔的x值
x = np.linspace(begin, end, sample_size, endpoint=False)
# 2. 生成对应的高斯分布噪声,形状与x一致
guass_noise = np.random.normal(mu, sigma, sample_size)
# 3. 计算纯净的sin(2πx)值,并加上噪声,得到真实的观测值y
y = np.sin(2 * np.pi * x) + guass_noise
# 4. 准备训练用的标签Y,将其变为列向量
train_y = y.reshape(sample_size, 1)
# 5. 构建设计矩阵X,这是多项式拟合的核心
# 矩阵大小为 [样本数, 多项式阶数+1]
train_x = np.zeros((sample_size, poly_order + 1))
# 生成一个从0到poly_order的指数序列,用于计算x的幂
exponents = np.arange(0, poly_order + 1)
for i in range(sample_size):
# 对于第i个样本点x[i],计算它的0次方、1次方...poly_order次方
row = np.ones(poly_order + 1) * x[i] # 先创建一个所有元素都为x[i]的数组
row = row ** exponents # 然后对每个元素进行幂运算
train_x[i] = row # 放入矩阵的第i行
return train_x, train_y, x, y
关键点解析与我的踩坑经验:
- 设计矩阵
train_x:这是理解整个实验的钥匙。假设我们用3阶多项式($w_0 + w_1x + w_2x^2 + w_3x^3$)拟合,对于单个数据点 $x_i$,我们需要计算 $[1, x_i, x_i^2, x_i^3]$ 作为一个行向量。train_x就是把所有样本点的这些行向量堆叠起来形成的矩阵。它建立了数据 $x$ 和模型参数 $w$ 之间的桥梁,因为我们的预测值就是 $train_x \cdot w$。 - 噪声标准差
sigma:这个参数控制着数据的“混乱”程度。我一开始把它设得太小(比如0.01),生成的数据点几乎就在正弦曲线上,导致过拟合现象不明显,实验效果大打折扣。后来调到0.05左右,数据既有规律可循,又有足够的随机性,过拟合和正则化的效果对比就非常鲜明了。建议你尝试0.02、0.05、0.1这几个值,直观感受一下噪声对数据的影响。 - 样本数量
sample_size:这是影响过拟合的关键因素之一。在后面的实验中我们会看到,当样本很少(比如10个)时,高阶多项式很容易“记住”每一个带噪声的点,从而过拟合。当样本增多(比如100个),模型被迫去学习更通用的规律,过拟合自然减轻。
生成数据后,我强烈建议你立刻画图看看。用 plt.scatter(x, y) 散点图观察你的数据分布,再用 plt.plot(x, np.sin(2*np.pi*x), ‘r-‘) 画出纯净的正弦波曲线进行对比。这能让你对你将要拟合的目标有一个最直观的认识。
3. 寻找“最优解”:两种视角下的数学推导
数据准备好了,接下来就是核心问题:怎么找到那组最优的参数 $w$,让我们的多项式曲线最贴合数据?我们有两种经典的思路:一种是直接通过数学公式算出“解析解”,另一种是用迭代优化的方法逼近“数值解”。我们先看第一种,它帮助我们建立最根本的数学直觉。
3.1 无正则化的最小二乘法:追求完美贴合
我们的目标是让预测值 $Xw$ 和真实值 $Y$ 的差距最小。这个差距用“误差平方和”来衡量,也就是损失函数 $E(w)$:
$$E(w) = \frac{1}{2}(Xw – Y)^T(Xw – Y)$$
这里的 $1/2$ 是为了后续求导后形式更简洁而加的系数,不影响最优解的位置。我们的任务就是找到让 $E(w)$ 最小的那个 $w$。
怎么找?回想一下高中数学,求一个函数的最小值,我们可以先求导,然后令导数等于零。对于向量 $w$,也是类似的道理。经过推导(过程略,但其实就是矩阵求导),我们得到最优解 $w^*$ 必须满足:
$$\frac{\partial E}{\partial w} = X^TXw – X^TY = 0$$
移项整理,就得到了著名的正规方程:
$$w^* = (X^TX)^{-1}X^TY$$
代码实现就一行,但内涵丰富:
w_analytic = np.linalg.inv(train_x.T.dot(train_x)).dot(train_x.T).dot(train_y)
我的经验与陷阱:
- 矩阵求逆的稳定性:
np.linalg.inv()是求矩阵的逆。当 $X^TX$ 这个矩阵接近“奇异”(简单理解就是行列式接近0,矩阵里的行或列近似存在线性关系)时,求逆会变得非常不稳定,计算结果可能溢出或产生极大的误差。在多项式拟合中,如果多项式阶数很高,而 $x$ 的取值范围又很小(比如0到1),$X^TX$ 的各列(代表 $x^0, x^1, x^2…$)可能会非常相关,导致这个问题。这是无正则化方法的一个固有缺陷。 - 计算效率:对于特征维度(即多项式阶数+1)不大的情况,这个方法很快。但如果维度成千上万,计算一个大矩阵的逆将非常耗时。
3.2 引入正则化:给模型加上“紧箍咒”
当我们用高阶多项式(比如9阶)去拟合只有10个数据点时,无正则化的方法会竭力让曲线穿过每一个点,包括噪声点,导致曲线剧烈震荡。这就是过拟合。为了解决它,我们需要在损失函数里增加一个“惩罚项”,专门打击那些绝对值过大的参数 $w$。最常用的惩罚项是L2范数(所有 $w_i$ 的平方和),这被称为岭回归或权重衰减。
新的损失函数 $ \widetilde{E}(w)$ 变成了:
$$\widetilde{E}(w) = \frac{1}{2}(Xw – Y)^T(Xw – Y) + \frac{\lambda}{2} w^Tw$$
等式右边多了一项 $\frac{\lambda}{2} w^Tw$。这里的 $\lambda$ 是一个超参数,你可以把它想象成“惩罚力度”。$\lambda$ 越大,对模型复杂度的惩罚就越重,模型就越倾向于选择更小的 $w$,曲线也就越平滑。
同样对 $w$ 求导并令其为零:
$$\frac{\partial \widetilde{E}}{\partial w} = X^TXw – X^TY + \lambda w = 0$$
整理后,得到带正则化的解析解:
$$w^* = (X^TX + \lambda I)^{-1}X^TY$$
注意,这里在 $X^TX$ 上加了一个 $\lambda I$,$I$ 是单位矩阵。这个小小的加法,就像给原本可能“病态”的 $X^TX$ 矩阵的主对角线元素都加了一个正数,立刻让它变得“健康”起来,求逆也稳定多了。
代码实现如下:
lamda = 1e-9 # 惩罚系数,需要根据实验调整
I = np.eye(train_x.shape[1]) # 生成一个单位矩阵
w_analytic_regularized = np.linalg.inv(train_x.T.dot(train_x) + lamda * I).dot(train_x.T).dot(train_y)
如何选择 $\lambda$?
这是正则化的艺术。$\lambda$ 太小,惩罚力度不够,过拟合依旧;$\lambda$ 太大,模型被过度压制,变成一条近乎水平的直线,导致“欠拟合”。在实验中,我们通常在一个很大的范围(比如 $10^{-12}$ 到 $10^{2}$)内,通过对数尺度(如 np.logspace(-12, 2, 50))采样许多 $\lambda$ 值,分别在训练集和验证集上测试效果,选择那个在验证集上表现最好的 $\lambda$。这个过程本身,就是机器学习调参的一个缩影。
4. 迭代优化之路:梯度下降与共轭梯度法实战
解析解虽然漂亮,但在很多现实问题中(比如特征维度极高、数据量极大,$X^TX$ 矩阵根本放不进内存),我们无法直接计算。这时,迭代优化方法就派上用场了。它们像“盲人爬山”一样,通过不断试探和调整,一步步逼近山顶(最优解)。
4.1 梯度下降法:最直观的“下山”策略
梯度下降法的思想非常直观:既然损失函数 $E(w)$ 的梯度 $\nabla E(w)$ 指向了函数值上升最快的方向,那么沿着它的反方向走一小步,函数值就应该下降。重复这个过程,我们就能走到一个(局部)最低点。
对于我们的带正则化损失函数,梯度已经求出来了:
$$\frac{\partial \widetilde{E}}{\partial w} = X^TXw – X^TY + \lambda w$$
那么,梯度下降的更新公式就是:
$$w_{new} = w_{old} – \alpha \cdot \frac{\partial \widetilde{E}}{\partial w}$$
这里 $\alpha$ 是学习率,也叫步长,是梯度下降中最重要的超参数之一。
我实现的梯度下降核心代码如下:
def gradient_descent(train_x, train_y, lamda, learning_rate=0.01, epsilon=1e-6, max_iter=100000):
"""
梯度下降法求解
:param learning_rate: 初始学习率
:param epsilon: 收敛阈值,当损失函数变化小于此值时停止
:param max_iter: 最大迭代次数,防止无限循环
"""
n_samples, n_features = train_x.shape
w = np.zeros((n_features, 1)) # 初始化参数,全零开始
history_loss = [] # 记录损失历史,用于画图观察
for i in range(max_iter):
# 计算当前梯度
gradient = train_x.T.dot(train_x).dot(w) - train_x.T.dot(train_y) + lamda * w
# 尝试更新参数
w_new = w - learning_rate * gradient
# 计算新旧损失
loss_old = compute_loss(train_x, train_y, w, lamda)
loss_new = compute_loss(train_x, train_y, w_new, lamda)
# 如果损失下降,则接受更新
if loss_new < loss_old:
w = w_new
history_loss.append(loss_new)
# 简单收敛判断:如果损失变化很小,则停止
if abs(loss_old - loss_new) < epsilon:
print(f‘梯度下降收敛于第{i+1}次迭代’)
break
else:
# 如果损失没有下降,说明步长太大,折半后退回上一步
learning_rate *= 0.5
print(f‘第{i+1}次迭代,损失未下降,学习率调整为{learning_rate:.6f}’)
return w, history_loss
踩坑实录与调参心得:
- 学习率 $\alpha$:这是最大的坑。我一开始设
learning_rate=0.1,结果损失函数不仅不降,反而爆炸式增长(NaN了)。这是因为步长太大,直接“跳”过了山谷,到了山的另一边更高的地方。一个实用的技巧是加入“回溯”机制:如果更新后损失反而变大,就拒绝这次更新,并将学习率减半,然后重试。这能保证损失函数总体是下降的。 - 初始值:对于我们这个凸二次函数,无论从全零向量还是随机小向量开始,最终都能收敛到全局最优。但对于更复杂的非凸函数(比如神经网络),初始值就至关重要了。
- 收敛判断:我设置了两个停止条件:一是损失变化小于阈值
epsilon,二是达到最大迭代次数。一定要设置最大迭代次数,防止程序因不收敛而卡死。 - 性能观察:梯度下降的收敛速度通常比较慢,从实验结果看,需要几万次迭代才能达到较高精度。你可以把
history_loss画出来,会看到一条缓慢下降并逐渐平坦的曲线。
4.2 共轭梯度法:更聪明的“下山”规划师
梯度下降法有个问题:它每次走的方向都是当前最陡的下山方向,但这可能导致“之字形”路线,效率低下。共轭梯度法是一种更高级的优化算法,它的目标是:每一次迭代,都沿着一个“共轭方向”前进,保证在每个方向上一步就走到位,从而理论上对于n维二次函数,最多n步就能收敛到最优解。
它的数学原理比梯度下降复杂,但我们可以直观理解:如果说梯度下降是“贪心”的近视眼,只看到眼前最陡的路;那么共轭梯度法则像一个有全局规划的向导,它选择的每一步方向,不仅考虑当前最陡,还考虑之前走过的方向,保证新方向与旧方向“共轭”(一种正交的推广),从而避免重复探索。
对于我们的问题,我们需要求解线性方程组 $(X^TX + \lambda I)w = X^TY$。共轭梯度法正是求解大型稀疏线性方程组的利器。
我的共轭梯度法实现核心代码:
def conjugate_gradient(train_x, train_y, lamda, epsilon=1e-10, max_iter=None):
n_features = train_x.shape[1]
# 构造系数矩阵A和向量b
A = train_x.T.dot(train_x) + lamda * np.eye(n_features)
b = train_x.T.dot(train_y)
# 初始化
w = np.zeros((n_features, 1)) # 解向量
r = b.copy() # 残差 r = b - A*w (初始时w=0, r=b)
p = r.copy() # 搜索方向
rs_old = r.T.dot(r) # 残差的内积
history_loss = []
for i in range(max_iter if max_iter else n_features * 10): # 设置一个宽松的迭代上限
Ap = A.dot(p) # 预计算,避免重复计算
alpha = rs_old / (p.T.dot(Ap)) # 计算步长
w = w + alpha * p # 更新解
r = r - alpha * Ap # 更新残差
rs_new = r.T.dot(r) # 计算新的残差内积
history_loss.append(compute_loss(train_x, train_y, w, lamda))
# 收敛判断:残差足够小
if rs_new < epsilon:
print(f‘共轭梯度收敛于第{i+1}次迭代’)
break
beta = rs_new / rs_old # 计算方向更新系数
p = r + beta * p # 更新搜索方向
rs_old = rs_new
return w, history_loss
性能对比的震撼: 运行代码后,你会看到一个神奇的现象:对于同样的问题,梯度下降需要数万次迭代,而共轭梯度法通常在10次迭代之内就收敛了!这就是算法效率的差距。共轭梯度法每次迭代的计算量略大于梯度下降(因为要多计算矩阵A和向量p的乘积),但迭代次数少几个数量级,总体速度快得多。
不过,共轭梯度法对数值精度更敏感,并且在问题条件数很大(即矩阵A的性质不好)时,可能需要更多的迭代或预处理技术。但在我们这个多项式拟合的例子上,它表现堪称完美。
5. 实验结果深度剖析:过拟合、正则化与算法对比
现在,让我们把所有的工具都用上,来看看各种因素是如何影响最终拟合效果的。我会用大量的对比实验,把“过拟合”和“正则化”这两个概念,深深地刻在你的脑子里。
5.1 多项式阶数:模型复杂度的双刃剑
我们固定训练集大小为10,使用无正则化的解析解,看看不同阶数的多项式会画出怎样的曲线。
| 多项式阶数 | 拟合曲线特点 | 训练误差 | 直观评价 |
|---|---|---|---|
| 1阶 (线性) | 一条试图贯穿波浪点的斜线。 | 很大 | 欠拟合。模型太简单,无法捕捉数据的波动规律。 |
| 3阶 | 一条平滑的曲线,大致跟随数据的趋势。 | 较小 | 拟合良好。模型复杂度与数据复杂度匹配,既不过分也不欠缺。 |
| 9阶 | 一条剧烈震荡的曲线,精确穿过每一个训练数据点。 | 接近0 | 严重过拟合。模型复杂度过高,它“记住”了包括噪声在内的所有训练数据细节,丧失了泛化能力。 |
关键洞察:高阶多项式拥有更多的参数($w_0$ 到 $w_9$),这意味着它拥有更强的“表达能力”或“模型容量”。当数据量少时,这个强大的容量没有被足够的数据约束,就会去拟合噪声,导致在训练集上表现完美,但在新数据上表现糟糕。这就像用一个能记住整本字典的“天才”去学造句,如果只给他10个例句,他可能生搬硬套造出语法正确但毫无意义的句子。
5.2 样本数量:数据是最好的正则化
过拟合的根本原因是“数据太少,想法(模型)太多”。那么,增加数据量会怎样?我们固定使用9阶多项式,但逐步增加训练样本。
- 训练集=10:如前所述,曲线疯狂震荡,过拟合。
- 训练集=20:震荡幅度明显减小,曲线开始显现正弦波的轮廓。
- 训练集=50:曲线已经相当平滑,非常接近真实的正弦函数。
- 训练集=100:拟合曲线几乎与真实的正弦波重合。
这个实验清晰地证明了 “数据是最好的正则化” 这一机器学习界的金句。更多的数据提供了更全面的信息,迫使模型去学习数据背后真正的、通用的规律,而不是偶然的噪声。
5.3 正则化强度 $\lambda$:寻找平衡点
当我们无法获取更多数据时,正则化就是对抗过拟合的利器。我们固定使用9阶多项式和10个训练样本,来调整 $\lambda$。
| $\lambda$ 范围 | 对模型的影响 | 训练集误差 | 验证集误差 | 状态 |
|---|---|---|---|---|
| 极小 (如 $10^{-40}$) | 惩罚几乎为零,退化为无正则化情况。 | 极低 | 很高 | 过拟合 |
| 适中 (如 $10^{-9}$) | 有效抑制了参数绝对值,使曲线平滑。 | 较低 | 最低 | 最佳拟合 |
| 极大 (如 $10^{2}$) | 惩罚过重,所有参数 $w$ 被压向零。 | 很高 | 很高 | 欠拟合 (曲线趋近于水平线) |
在实际操作中,你需要用一个独立的验证集(不是训练集,也不是最终测试集)来评估不同 $\lambda$ 值的效果。画出验证集误差随 $\lambda$ 变化的曲线,你会发现一个“U”形或“V”形谷底,那个谷底对应的 $\lambda$ 就是比较理想的值。这个过程叫做超参数调优。
5.4 优化算法对比:梯度下降 vs. 共轭梯度
最后,我们对比一下两种迭代优化方法在相同设置下的表现。
| 对比维度 | 梯度下降法 | 共轭梯度法 |
|---|---|---|
| 迭代次数 | 通常需要 $10^4$ 到 $10^5$ 量级 | 通常只需 $10^0$ 到 $10^1$ 量级 (个位数到十几次) |
| 收敛速度 | 慢,呈线性收敛 | 快,对于n维二次问题,最多n步收敛 |
| 参数调节 | 学习率 $\alpha$ 非常关键,需要小心调整 | 无需设置学习率,步长由算法自动计算 |
| 稳定性 | 相对稳定,加入学习率衰减后更鲁棒 | 对数值精度和矩阵条件数更敏感 |
| 适用场景 | 通用,是深度学习的主流优化器基础 | 特别适合求解大规模稀疏线性方程组 |
在我的实验复现中,设置收敛精度为 $10^{-6}$,梯度下降法需要数万次迭代,而共轭梯度法仅需5到7次。这个效率差距是数量级的。所以,对于像线性回归、岭回归这类损失函数是凸二次的问题,如果条件允许,共轭梯度法通常是更优的选择。 梯度下降法的价值在于其普适性,它可以被推广到(随机梯度下降)来处理海量数据,也可以用于优化像神经网络那样非凸的复杂函数。
做完这一系列的实验,从生成数据、推导公式、编写代码,到调整参数、观察现象、分析结果,你对“过拟合”的理解就不再是书本上干巴巴的定义,而是变成了眼前一条条或平滑或震荡的曲线,和一组组对比鲜明的误差数字。你会真正理解,机器学习模型不是在追求对训练数据的“完美复刻”,而是在“拟合能力”和“泛化能力”之间走钢丝,寻找那个微妙的、动态的平衡点。而正则化、增加数据、选择合适模型复杂度,就是我们手中的平衡杆。
更多推荐
所有评论(0)