短松江月

最小二乘法

· simons ·
最小二乘法 线性回归 统计学习 优化算法 参数估计 数学建模

十年没碰数学了,重新捡起来时发现很多基础都模糊了。机器学习离不开数学,这篇算是给自己补课,尽量写得让普通人也能看懂。

一、开篇:我们要解决什么问题?

想象你是一个健身房的教练,每天都有会员来问:“教练,我体脂率多少?”

你手里有一把体脂秤,但这玩意儿不便宜,也不是每个会员都愿意测。但你发现一个规律:体脂率好像和身高、体重、年龄、性别有关系

于是你想:能不能找到一个公式,输入这四个数字,就能估算出体脂率?

这就是回归问题的本质——根据已知信息(输入),预测未知结果(输出)

用数学语言重新描述

  • 输入(特征):身高、体重、年龄、性别 → 记作 $x$,是一个包含 4 个数字的向量
  • 输出(目标):体脂率 → 记作 $y$,是一个数字
  • 我们要找的:一个函数 $f$,让 $f(x)$ 尽可能接近真实的 $y$
$$y = f(x)$$

但现实中,这个 $f$ 我们不知道是什么。我们只有一堆测量数据:

会员1: 身高175cm, 体重70kg, 25岁, 男 → 体脂率18.5%
会员2: 身高165cm, 体重55kg, 30岁, 女 → 体脂率25.2%
会员3: 身高180cm, 体重80kg, 22岁, 男 → 体脂率15.8%
...

这些数据记作 $\{(x_1, y_1), (x_2, y_2), ..., (x_n, y_n)\}$,其中 $n$ 是会员数量。


二、第一个关键问题:为什么数据有误差?

你可能注意到了,即使两个人身高体重年龄性别都一样,体脂率也可能不同

为什么?

  1. 测量误差:体脂秤本身有误差(±1-2%)
  2. 个体差异:有人肌肉多,有人脂肪多
  3. 隐藏因素:饮食习惯、遗传、运动量等等我们没记录的信息
  4. 测量时机:早上和晚上测,喝水前后测,结果都不一样

所以,真实情况应该写成:

$$y_i = f(x_i) + \varepsilon_i$$

其中:

  • $f(x_i)$ 是"真实的体脂率"
  • $\varepsilon_i$ 是"噪声"(各种误差和未知因素)

最小二乘法要做的事情,就是从这些带噪声的数据中,找出最可能的 $f$


三、最小二乘法的核心思想:让误差尽可能小

假设我们猜测函数 $f$ 的形式是:

$$f(x) = \theta_0 + \theta_1 \times \text{身高} + \theta_2 \times \text{体重} + \theta_3 \times \text{年龄} + \theta_4 \times \text{性别}$$

这里 $\theta_0, \theta_1, \theta_2, \theta_3, \theta_4$ 是五个未知的参数,我们要通过数据把它们找出来。

怎么找?看预测有多准

对于每个会员 $i$,我们可以算出:

  • 预测值:$f(x_i) = \theta_0 + \theta_1 \times x_{i1} + \theta_2 \times x_{i2} + ...$
  • 真实值:$y_i$(实际测量的体脂率)
  • 误差:$e_i = y_i - f(x_i)$

如果预测准,误差就小;如果预测不准,误差就大。

为什么用"平方"误差?

最小二乘法的名字来源于它的目标:让所有误差的平方之和最小

$$\text{总误差} = \sum_{i=1}^{n} (y_i - f(x_i))^2$$

你可能会问:为什么不直接用误差 $e_i$,而要用平方 $e_i^2$?

三个原因:

1. 正负误差不能抵消

如果直接加误差:

  • 会员1:预测18%,实际20%,误差 = -2%
  • 会员2:预测22%,实际20%,误差 = +2%
  • 总误差 = -2% + 2% = 0%?

看起来很准,但其实两个都预测错了!

用平方就没这个问题:

  • 总误差 = $(-2)^2 + 2^2 = 4 + 4 = 8$

2. 平方会"惩罚"大误差

看这两组预测:

会员方案A误差方案B误差
11%5%
21%1%
平方和226

方案A比较均衡,方案B有一个离谱的预测。平方误差会让方案B的总分更差,这符合我们的直觉——宁可都差一点,也不要有一个特别离谱的

3. 数学上好处理

平方函数处处可导,方便用微积分求最优解。这是技术细节,初学者可以先跳过。


四、动手计算:用四个会员的数据找参数

假设我们有这四个会员的数据:

编号身高(cm)体重(kg)年龄性别体脂率(%)
11757025男(1)18.5
21655530女(0)25.2
31808022男(1)15.8
41706028女(0)22.1

第一步:把数据写成矩阵

为了方便计算,我们把数据排列成表格形式(矩阵):

$$X = \begin{bmatrix} 1 & 175 & 70 & 25 & 1 \\ 1 & 165 & 55 & 30 & 0 \\ 1 & 180 & 80 & 22 & 1 \\ 1 & 170 & 60 & 28 & 0 \end{bmatrix}$$$$y = \begin{bmatrix} 18.5 \\ 25.2 \\ 15.8 \\ 22.1 \end{bmatrix}$$$$\theta = \begin{bmatrix} \theta_0 \\ \theta_1 \\ \theta_2 \\ \theta_3 \\ \theta_4 \end{bmatrix}$$

第二步:用公式直接算出最优参数

这里有个神奇的公式(推导过程很复杂,但用起来很简单):

$$\theta = (X^T X)^{-1} X^T y$$

翻译成人话就是:

  • $X^T$ 是 $X$ 的转置(行列互换)
  • $(X^T X)^{-1}$ 是矩阵的逆(类似倒数)
  • 最后左乘 $X^T y$

第三步:用 Python 计算

import numpy as np

# 输入数据(第一列是1,代表常数项)
# 为了简洁,这里只使用 4 个数据
X = np.array([
    [1, 175, 70, 25, 1], # 常数,身高,体重,年龄,性别
    [1, 165, 55, 30, 0],
    [1, 180, 80, 22, 1],
    [1, 170, 60, 28, 0]
])

# 体脂率
y = np.array([18.5, 25.2, 15.8, 22.1]) # 这里也是 4 个数据(y 的长度必须等于 X 的行数)

# 计算最优参数
theta = np.linalg.inv(X.T @ X) @ X.T @ y

print("找到的参数:", theta)

对于数学基本知识薄弱的同学,简单解释一下:

  1. 为什么 $y$ 的长度必须等于 $X$ 的行数?

我们的模型是:

$$ y_i = \theta_0 + \theta_1 x_{i1} + \theta_2 x_{i2} + \cdots + \theta_d x_{id} + \varepsilon_i $$

theta = np.linalg.inv(X.T @ X) @ X.T @ y

  • X.T:特征矩阵 X 的转置
  • X.T @ X:特征矩阵与其转置的乘积(得到方阵)
  • np.linalg.inv():求矩阵的逆
  • @:Python中的矩阵乘法运算符

这也就是前面提到的公式: $\theta = (X^TX)^{-1}X^Ty$

假设算出来 $\theta = [15.2, -0.05, 0.25, 0.18, -2.5]$

第四步:解读结果

$$\text{体脂率} = 15.2 - 0.05 \times \text{身高} + 0.25 \times \text{体重} + 0.18 \times \text{年龄} - 2.5 \times \text{性别}$$

每个系数告诉我们什么?

  • $\theta_0 = 15.2$:基础值(当其他都是0时的体脂率)
  • $\theta_1 = -0.05$:身高每增加1cm,体脂率降低0.05%(个子高的人体脂率往往低)
  • $\theta_2 = 0.25$:体重每增加1kg,体脂率升高0.25%(符合直觉)
  • $\theta_3 = 0.18$:年龄每增加1岁,体脂率升高0.18%(年龄大了新陈代谢慢)
  • $\theta_4 = -2.5$:男性(编码为1)比女性(编码为0)体脂率低2.5%

验证一下:预测会员1的体脂率

$$\begin{align} \text{预测值} &= 15.2 - 0.05 \times 175 + 0.25 \times 70 + 0.18 \times 25 - 2.5 \times 1 \\ &= 15.2 - 8.75 + 17.5 + 4.5 - 2.5 \\ &= 25.95\% \end{align}$$

等等,会员1的真实体脂率是18.5%,我们预测了25.95%,差了7.45%!

这很正常!因为:

  1. 我们只有4个样本,数据太少
  2. 真实关系可能不是线性的
  3. 体脂率受很多我们没测量的因素影响

但这就是最小二乘法的结果——在这4个样本上,没有任何其他参数能让总平方误差更小


五、进阶:当直线不够用时

上面的模型假设体脂率和各个特征之间是直线关系。但现实往往更复杂。

$$f(x) = \theta_0 + \theta_1 \times \text{体重} + \theta_2 \times \text{体重}^2$$$$f(x) = \theta_0 + \theta_1 \times \sin(x) + \theta_2 \times \cos(x)$$

基函数的概念

我们把输入 $x$ 先变换一下,得到新的特征 $\phi(x)$,然后在这些新特征上做线性回归。

例子1:多项式基函数

  • 原始输入:$x = [\text{体重}]$
  • 变换后:$\phi(x) = [1, \text{体重}, \text{体重}^2, \text{体重}^3]$

例子2:三角基函数

  • 原始输入:$x$
  • 变换后:$\phi(x) = [1, \sin(x), \cos(x), \sin(2x), \cos(2x), ...]$

虽然模型对原始输入 $x$ 是非线性的,但对参数 $\theta$ 仍然是线性的,所以还能用最小二乘法

实际例子:拟合复杂曲线

$$y = \frac{\sin(\pi x)}{\pi x} + 0.1x + \text{噪声}$$$$\phi(x) = [1, \sin(0.5x), \cos(0.5x), \sin(x), \cos(x), ..., \sin(7.5x), \cos(7.5x)]$$
import numpy as np
import matplotlib.pyplot as plt

# 生成50个训练点
n = 50
x = np.linspace(-3, 3, n)
pix = np.pi * x
y_true = np.sin(pix) / pix + 0.1 * x
y = y_true + 0.05 * np.random.randn(n)  # 加噪声

# 构建31个基函数(1个常数项 + 15对sin/cos)
Phi = np.ones((n, 31))
for j in range(1, 16):
    Phi[:, 2*j-1] = np.sin(j * 0.5 * x)
    Phi[:, 2*j] = np.cos(j * 0.5 * x)

# 最小二乘求解
theta = np.linalg.inv(Phi.T @ Phi) @ Phi.T @ y

# 在1000个点上预测
X_test = np.linspace(-3, 3, 1000)
Phi_test = np.ones((1000, 31))
for j in range(1, 16):
    Phi_test[:, 2*j-1] = np.sin(j * 0.5 * X_test)
    Phi_test[:, 2*j] = np.cos(j * 0.5 * X_test)

y_pred = Phi_test @ theta

# 画图
plt.figure(figsize=(10, 6))
plt.plot(X_test, y_pred, 'g-', linewidth=2, label='拟合曲线')
plt.plot(x, y, 'bo', markersize=5, label='训练数据')
plt.legend()
plt.title('用三角基函数拟合复杂曲线')
plt.xlabel('x')
plt.ylabel('y')
plt.grid(True, alpha=0.3)
plt.show()

运行后你会看到,虽然训练数据只有50个点且带噪声,但拟合曲线非常平滑地捕捉到了真实函数的形状。


六、加权最小二乘:不是所有数据都同等重要

回到健身房的例子。你发现有些数据来自专业的医用体脂仪(误差±0.5%),有些来自便宜的家用体脂秤(误差±2%)。显然,前者更可信。

怎么让模型"重视"可靠的数据?给每个样本加个权重 $w_i$:

$$\text{总误差} = \sum_{i=1}^{n} w_i \times (y_i - f(x_i))^2$$
  • 可靠的数据:$w_i = 10$(权重大,影响大)
  • 不可靠的数据:$w_i = 1$(权重小,影响小)
$$\theta = (\Phi^T W \Phi)^{-1} \Phi^T W y$$

其中 $W$ 是对角矩阵,对角线上是各个权重 $w_1, w_2, ..., w_n$。

例子

# 假设前两个样本很可靠,后两个不太可靠
W = np.diag([10, 10, 1, 1])  # 对角权重矩阵

# 加权最小二乘
theta_weighted = np.linalg.inv(X.T @ W @ X) @ X.T @ W @ y

这样算出来的参数会更贴近可靠的数据。


七、大数据时代:当数据多到算不动

前面的公式 $\theta = (X^T X)^{-1} X^T y$ 有个问题:需要计算矩阵的逆

如果有100万个样本,1000个特征,$X^T X$ 是一个 1000×1000 的矩阵,求逆需要几个GB的内存,而且计算慢得要死。

随机梯度下降:一次只看一个样本

换个思路:我们不一次性用全部数据,而是每次随机抽一个样本,朝着误差减小的方向调整参数

具体步骤:

  1. 随机初始化参数 $\theta$(比如全部设为0,或者随机小数)

  2. 重复以下步骤

    • 随机选一个样本 $(x_i, y_i)$
    • 计算这个样本的预测误差:$e_i = f_\theta(x_i) - y_i$
    • 调整参数:$\theta \leftarrow \theta - \varepsilon \times e_i \times \phi(x_i)$

    这里 $\varepsilon$ 叫学习率(步长),通常是一个小数,比如 0.01。

  3. 直到参数不再变化(收敛)

为什么这样能行?

想象你在一个山谷里,想找到最低点(对应误差最小)。你看不到全局,但能感觉到脚下的坡度。你就沿着下坡的方向走一步,再看脚下的坡度,再走一步……最终会走到山谷底部。

每个样本的误差 $e_i$ 就是局部的坡度。虽然每次只看一个样本,但只要方向大致正确,多走几步也能到达最优点。

Python 实现

# 随机梯度下降求解
def sgd_least_squares(X, y, learning_rate=0.01, max_iter=10000):
    n, d = X.shape
    theta = np.random.randn(d) * 0.01  # 随机初始化
    
    for iteration in range(max_iter):
        # 随机选一个样本
        i = np.random.randint(n)
        
        # 计算预测值和误差
        pred = X[i] @ theta
        error = pred - y[i]
        
        # 更新参数
        theta_new = theta - learning_rate * error * X[i]
        
        # 检查是否收敛
        if np.linalg.norm(theta_new - theta) < 1e-6:
            print(f"收敛于第 {iteration} 次迭代")
            break
            
        theta = theta_new
    
    return theta

# 使用SGD
theta_sgd = sgd_least_squares(X, y, learning_rate=0.01)
print("SGD结果:", theta_sgd)

# 对比解析解
theta_exact = np.linalg.inv(X.T @ X) @ X.T @ y
print("解析解:", theta_exact)

运行后你会发现,SGD 的结果和解析解非常接近,但计算速度快得多(尤其在数据量大时)。

学习率的选择

  • 太大(如 1.0):参数跳来跳去,不收敛
  • 太小(如 0.0001):收敛太慢,要迭代很多次
  • 刚好(如 0.01):又快又稳

实践中常用学习率衰减:开始大一点(比如0.1),跑几千次迭代后逐渐减小(比如0.01,0.001)。


八、核方法:处理复杂关系的另一种方式

有时候我们不想手动设计基函数,而是让模型"自动"学习复杂的关系。核方法是一个经典技巧。

高斯核函数

$$K(x, c) = \exp\left(-\frac{\|x - c\|^2}{2h^2}\right)$$

这个函数有个特点:

  • 当 $x$ 接近 $c$ 时,$K(x, c) \approx 1$(很相似)
  • 当 $x$ 远离 $c$ 时,$K(x, c) \approx 0$(不相似)

其中 $h$ 叫带宽,控制"相似"的范围。

核模型的思路

对于每个训练样本 $(x_i, y_i)$,我们在 $x_i$ 的位置放一个"钟形曲线"(高斯核),高度是 $\theta_i$。

$$f(x) = \sum_{i=1}^{n} \theta_i \times K(x, x_i)$$

直觉理解

  • 如果 $x$ 靠近会员1,那 $K(x, x_1)$ 很大,$\theta_1$ 的影响就大
  • 如果 $x$ 远离所有训练样本,所有 $K(x, x_i)$ 都很小,预测值接近0

用 SGD 训练核模型

def gaussian_kernel(x, centers, h=0.3):
    """计算x到所有中心点的高斯核"""
    n = len(centers)
    K = np.zeros(n)
    for i in range(n):
        K[i] = np.exp(-((x - centers[i])**2) / (2 * h**2))
    return K

# 训练数据
x = np.linspace(-3, 3, 50)
y = np.sin(np.pi * x) / (np.pi * x) + 0.1 * x + 0.05 * np.random.randn(50)

# 用SGD训练
theta = np.random.randn(50) * 0.01
learning_rate = 0.1

for iteration in range(10000):
    i = np.random.randint(50)  # 随机选样本
    
    # 计算核向量
    K_i = gaussian_kernel(x[i], x, h=0.3)
    
    # 预测和误差
    pred = K_i @ theta
    error = pred - y[i]
    
    # SGD更新
    theta = theta - learning_rate * error * K_i
    
    if iteration % 1000 == 0:
        total_error = np.mean((y - [gaussian_kernel(x[j], x) @ theta 
                                     for j in range(50)])**2)
        print(f"迭代 {iteration}, 误差 {total_error:.4f}")

这个方法在处理高维、非线性问题时特别有用。


九、几何直觉:投影的魔法

最小二乘法背后有个美妙的几何解释。

把数据想象成空间中的点

每个样本是一个点:

  • 输入 $x_i$ 决定点的位置
  • 输出 $y_i$ 是这个点在某个方向上的投影

所有可能的预测值 $f(x_i)$ 构成一个子空间(比如一个平面)。

观测值带噪声,偏离了子空间

真实的输出 $\Phi\theta^*$(如果没有噪声)一定在这个子空间里。但实际观测值 $y$ 因为有噪声,偏离了子空间。

最小二乘法做的事

把观测值 $y$ 垂直投影到子空间上,得到 $\hat{y}$。这个投影就是最接近 $y$ 的、在子空间里的点。

$$\hat{y} = \Phi \hat{\theta} = \Phi (\Phi^T \Phi)^{-1} \Phi^T y = \Pi_\Phi y$$

其中 $\Pi_\Phi$ 是投影矩阵。

关键洞察:投影操作去掉了垂直于子空间的噪声成分,保留了有用的信号。


十、统计性质:为什么最小二乘法可靠?

无偏性

$$E[\hat{\theta}] = \theta^*$$

翻译成人话:虽然每次测量有误差,但多测几次平均一下,估计是准确的

渐近一致性

即使我们的模型不完美(比如真实关系是非线性的,但我们用了线性模型),只要样本数量足够多,最小二乘估计会收敛到"最优的线性近似"。


十一、实战建议

1. 从简单模型开始

不要一上来就用复杂的模型。先试试线性回归,看看效果如何。如果不够好,再考虑:

  • 增加多项式项($x^2, x^3$)
  • 使用三角函数基
  • 尝试核方法

2. 数据预处理很重要

  • 标准化:把特征缩放到相同范围(比如都在0-1之间)

    from sklearn.preprocessing import StandardScaler
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)
  • 去除异常值:极端数据会严重影响结果

3. 交叉验证

不要只在训练数据上测试!留出一部分数据(比如20%)作为测试集,看模型在没见过的数据上表现如何。

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

# 在训练集上拟合
theta = np.linalg.inv(X_train.T @ X_train) @ X_train.T @ y_train

# 在测试集上评估
y_pred = X_test @ theta
test_error = np.mean((y_test - y_pred)**2)
print(f"测试误差:{test_error}")

4. 可视化很重要

画图能帮你快速发现问题:

  • 散点图:看数据分布
  • 拟合曲线:看模型效果
  • 残差图:看误差分布
import matplotlib.pyplot as plt

# 预测vs真实
plt.scatter(y, y_pred, alpha=0.5)
plt.plot([y.min(), y.max()], [y.min(), y.max()], 'r--')
plt.xlabel('真实值')
plt.ylabel('预测值')
plt.title('预测效果')
plt.show()

# 残差分布
residuals = y - y_pred
plt.hist(residuals, bins=30)
plt.xlabel('残差')
plt.ylabel('频数')
plt.title('残差分布(应该接近正态分布)')
plt.show()

十二、总结:最小二乘法的精髓

回到开头的问题:我们想根据身高体重年龄性别预测体脂率。

最小二乘法给了我们一套完整的方案:

  1. 建模:假设输出是输入的某种函数(线性、多项式、核函数等)
  2. 训练:找到参数,让预测值和真实值的平方误差最小
  3. 求解
    • 数据少:用解析解 $\theta = (X^T X)^{-1} X^T y$
    • 数据多:用随机梯度下降
  4. 评估:在新数据上测试,看预测是否准确

这个方法简单、高效、可靠,是机器学习的基石。后续学习的岭回归、LASSO、支持向量机、神经网络,都是在这个基础上发展出来的。

最重要的:动手实践!把上面的代码跑一遍,改改参数看看效果。数学公式只有在代码里跑起来,才会真正变成你的工具。


十三、附录:常见问题

Q1: 为什么公式里有个 $1/2$?

$$J(\theta) = \frac{1}{2}\sum_{i=1}^{n}(y_i - f(x_i))^2$$

答:纯粹为了求导方便。求导时平方会带出一个2,除以2之后正好抵消。不影响最优解的位置。

Q2: 矩阵不可逆怎么办?

如果特征之间完全线性相关(比如"身高(cm)“和"身高(m)“同时存在),$X^T X$ 会不可逆。

解决方法:

  • 去掉冗余特征
  • 岭回归(加一个小正数到对角线上)
  • 伪逆 np.linalg.pinv()

Q3: 学习率怎么选?

经验值:

  • 开始试 0.1
  • 如果不收敛,减半:0.05, 0.025, 0.0125…
  • 如果收敛太慢,翻倍:0.2, 0.4…

也可以用学习率衰减

learning_rate = initial_lr / (1 + decay_rate * iteration)

Q4: 怎么知道模型拟合得好不好?

看几个指标:

  • 均方误差(MSE):$\frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2$,越小越好
  • R²分数:1 - MSE/Var(y),接近1最好
  • 可视化:画拟合曲线,看是否符合直觉

Q5: 过拟合怎么办?

如果训练误差很小,但测试误差很大,说明过拟合了。

解决方法:

  • 减少特征数量或基函数数量
  • 增加训练数据
  • 使用正则化(岭回归、LASSO)
  • 交叉验证选择模型复杂度

希望这篇文章能帮你真正理解最小二乘法!记住:看懂不如做懂,做一遍胜过看十遍

十四、知识图谱

最小二乘法
├── 基本形式:min ||Φθ - y||²
├── 解析解:θ = Φ†y = (ΦᵀΦ)⁻¹Φᵀy
├── 加权形式:min ||W^(1/2)(Φθ - y)||²
├── 核方法:将Φ替换为核矩阵K
├── 理论性质
│   ├── SVD分解理解
│   ├── 几何解释(正交投影去噪)
│   └── 统计性质(无偏性)
└── 大规模算法
    └── 随机梯度下降(SGD)

十五、关键公式总结

概念公式
目标函数$J_{LS} = \frac{1}{2}\sum_{i=1}^n(f_\theta(x_i)-y_i)^2$
正规方程$\Phi^T\Phi\theta = \Phi^Ty$
最小二乘解$\hat{\theta}_{LS} = (\Phi^T\Phi)^{-1}\Phi^Ty$
SGD更新$\theta \leftarrow \theta - \varepsilon \phi(x_i)(f_\theta(x_i)-y_i)$
预测投影$\hat{y} = \Pi_\Phi y$