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)

我的经验与陷阱:

  1. 矩阵求逆的稳定性np.linalg.inv() 是求矩阵的逆。当 $X^TX$ 这个矩阵接近“奇异”(简单理解就是行列式接近0,矩阵里的行或列近似存在线性关系)时,求逆会变得非常不稳定,计算结果可能溢出或产生极大的误差。在多项式拟合中,如果多项式阶数很高,而 $x$ 的取值范围又很小(比如0到1),$X^TX$ 的各列(代表 $x^0, x^1, x^2…$)可能会非常相关,导致这个问题。这是无正则化方法的一个固有缺陷。
  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

踩坑实录与调参心得:

  1. 学习率 $\alpha$:这是最大的坑。我一开始设 learning_rate=0.1,结果损失函数不仅不降,反而爆炸式增长(NaN了)。这是因为步长太大,直接“跳”过了山谷,到了山的另一边更高的地方。一个实用的技巧是加入“回溯”机制:如果更新后损失反而变大,就拒绝这次更新,并将学习率减半,然后重试。这能保证损失函数总体是下降的。
  2. 初始值:对于我们这个凸二次函数,无论从全零向量还是随机小向量开始,最终都能收敛到全局最优。但对于更复杂的非凸函数(比如神经网络),初始值就至关重要了。
  3. 收敛判断:我设置了两个停止条件:一是损失变化小于阈值 epsilon,二是达到最大迭代次数。一定要设置最大迭代次数,防止程序因不收敛而卡死。
  4. 性能观察:梯度下降的收敛速度通常比较慢,从实验结果看,需要几万次迭代才能达到较高精度。你可以把 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次。这个效率差距是数量级的。所以,对于像线性回归、岭回归这类损失函数是凸二次的问题,如果条件允许,共轭梯度法通常是更优的选择。 梯度下降法的价值在于其普适性,它可以被推广到(随机梯度下降)来处理海量数据,也可以用于优化像神经网络那样非凸的复杂函数。

做完这一系列的实验,从生成数据、推导公式、编写代码,到调整参数、观察现象、分析结果,你对“过拟合”的理解就不再是书本上干巴巴的定义,而是变成了眼前一条条或平滑或震荡的曲线,和一组组对比鲜明的误差数字。你会真正理解,机器学习模型不是在追求对训练数据的“完美复刻”,而是在“拟合能力”和“泛化能力”之间走钢丝,寻找那个微妙的、动态的平衡点。而正则化、增加数据、选择合适模型复杂度,就是我们手中的平衡杆。

更多推荐