拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

线性回归原理与Python手写实现:从最小二乘到梯度下降

线性回归原理与Python手写实现:从最小二乘到梯度下降

机器学习入门,几乎每个人都从线性回归开始;但真正把线性回归吃透的,其实不多。我见过太多人写 (y = wx + b) 没问题,调 sklearn 的LinearRegression也很熟练,可一旦被问到“为什么最小二乘要用平方误差而不是绝对值”“正规方程和梯度下降到底什么关系”,就卡住了。这篇文章就是来补这块短板的:从数学原理一路推到代码实现,全程只用 Python 和 numpy,不依赖任何现成的机器学习库,把线性回归每一步掰开揉碎。

内容会覆盖最关键的几个问题:线性回归到底在解决什么、损失函数为什么是误差平方和、正规方程怎么推导、梯度下降为什么绕不开、代码怎么写与怎么诊断。无论你是在准备机器学习期末复习,还是刚开始接触机器学习算法,又或者只是想把底层原理搞明白,这篇文章都应该能帮到你。

1. 线性回归要解决的,从来不只是“画一条直线”

1.1 试想一个真实场景:房价预估

假设你手里有一批房屋数据,每套房有面积、卧室数量、周边配套评分,还有最终成交价。你现在拿到一套还没有定价的新房源,想预估一个合理的挂牌价。最朴素的思路是什么?找出面积、卧室数、评分对价格的共同影响,用它们算出价格——这正是线性回归在做的事。

更正式地说,我们有一组输入特征 (x^{(i)} = (x_1^{(i)}, x_2^{(i)}, ..., x_p^{(i)})),和一个连续输出 (y^{(i)})。线性回归假设输出是输入的线性组合,再加一个偏置项:

[ y = w_1 x_1 + w_2 x_2 + ... + w_p x_p + b ]

写成紧凑的向量形式就是:

[ y = \mathbf{w}^T \mathbf{x} + b ]

目标是根据已知数据,找到一组最合适的 ((\mathbf{w}, b)),让预测值 (\hat{y}) 尽量接近真实值 (y)。这件事在机器学习里叫“回归”,因为输出是一个连续数值,而不是离散类别。很多同学在入门时容易把回归和分类混在一起,记住一个判断标准就行:输出是“多少”就是回归,输出是“哪一类”就是分类。

上面这个例子是经典的“监督学习”任务。监督学习本质上是“从带标签的数据中学习映射”,线性回归是其中最直观的表达式,也是后面逻辑回归、神经网络、支持向量机等模型的逻辑起点。你要是不把这一步搞清楚,后面看复杂模型大概率也是半懂不懂,因为损失的构造方式、参数求解思路、过拟合风险控制,几乎都能在线性回归里找到原型。

1.2 “线性”两个字到底意味着什么

很多初学者一听“线性回归”,第一反应是:只能拟合一条直线。这个理解不完全对。线性回归的“线性”指的是对参数 (\mathbf{w}) 线性,而不是对特征 (x) 线性。

什么意思?就是说模型长这样:

[ \hat{y} = w_1 x_1 + w_2 x_2 + b ]

是线性的;但如果我把 (x_1^2) 也当成一个特征放进去:

[ \hat{y} = w_1 x_1 + w_2 x_2^2 + b ]

这个模型相对于 (x_1) 和 (x_2) 是非线性的,但相对于参数 (w_1, w_2, b) 仍然是线性的。所以在实际项目里,我们可以通过构造多项式特征,让线性回归拟合曲线。这种做法叫“多项式回归”,严格来说它仍然属于线性回归家族,因为求解方法完全不变。

换句话说,线性回归不是一个只能画直线的玩具,它是一个“用线性参数组合去近似任意函数”的工具。这个认知很重要,否则你会在后面学特征工程的时候产生困惑:为什么明明数据呈曲线,还能用线性回归处理?答案就是,你其实是在用 (x^2, x^3, \log x) 等扩展特征,让线性模型在“特征空间”里照样可以拟合非线性关系。

2. 为什么损失函数偏偏是误差平方和:最小二乘的两种解读

2.1 残差不能直接求和:一场正负抵消的骗局

有了模型,下一个问题是:怎样才算“尽量接近”?我们需要一个量化标准,让程序能判断某组参数好不好。最直接的想法是计算每个样本的预测误差(残差):

[ e_i = y_i - \hat{y}_i ]

然后把这些误差加起来当作总损失。听起来挺合理,但立刻会遇到问题:误差有正有负,直接求和会互相抵消。假设有三个样本,误差分别是 +3、-3、+3,总和是 3,实际上模型已经偏离很大了,可损失看着不大。这不是个好指标。

那取绝对值再求和?也就是平均绝对误差(MAE):

[ L = \frac{1}{n}\sum_{i=1}^{n} |y_i - \hat{y}_i| ]

这个方案解决了正负抵消的问题,也比平方误差更抗异常值。但它在数学上有个麻烦:绝对值函数在 0 处不可导。虽然可以用次梯度之类的手段处理,但最优解可能不唯一,优化起来也更别扭。在梯度下降大行其道的时代,不可导意味着很多现成的优化工具用不了。

于是人们盯上了平方误差:

[ L = \frac{1}{n}\sum_{i=1}^{n} (y_i - \hat{y}_i)^2 ]

它有三个非常讨喜的性质:第一,平方之后所有残差都是非负的,正负不会抵消;第二,它是一个处处可导的凸函数,有唯一的全局最小值,用梯度下降或正规方程都能稳定求解;第三,平方对大误差的惩罚比小误差重得多——误差 2 的惩罚是误差 1 的四倍,这会让模型更愿意照顾那些“错得离谱”的样本。

这第三种性质是一把双刃剑:好处是模型会比较认真对待每一个样本,坏处是它会被离群点牵着鼻子走。这一点我放到后面第 6 章专门讲,这里先不展开。

把这三种候选损失放在一起看会更清楚:

损失形式正负抵消可导性对异常值敏感度典型使用场景
残差和严重——基本不用
绝对值 / MAE不抵消在0处不可导低鲁棒回归
平方和 / MSE不抵消处处可导高标准线性回归、神经网络损失

正因为平方误差的计算过程叫“最小化误差平方和”,所以这种方法也被称为“最小二乘法”(Ordinary Least Squares, OLS)。

2.2 高斯视角:最小二乘等价于极大似然估计

如果你觉得上面只是“平方用起来方便”的工程理由,那就低估了最小二乘的数学地位。19 世纪初,高斯在预测天体运行轨道时发现,如果在假设误差服从正态分布的前提下做极大似然估计,推出来的结果恰好就是最小二乘。

我简单推一遍。假设每个样本的真实关系是:

[ y_i = \mathbf{w}^T \mathbf{x}_i + b + \varepsilon_i ]

其中误差 (\varepsilon_i) 服从均值为 0、方差为 (\sigma^2) 的正态分布。那么给定参数后,观测到 (y_i) 的概率是:

[ P(y_i | \mathbf{x}_i; \mathbf{w}, b) = \frac{1}{\sqrt{2\pi}\sigma} \exp\left(-\frac{(y_i - \mathbf{w}^T\mathbf{x}_i - b)^2}{2\sigma^2}\right) ]

所有样本独立同分布,所以整个训练集的似然函数是这些概率的乘积。取对数后得到:

[ \ln L = \sum_{i=1}^{n} \left[ -\frac{1}{2}\ln(2\pi\sigma^2) - \frac{(y_i - \mathbf{w}^T\mathbf{x}_i - b)^2}{2\sigma^2} \right] ]

最大化这个对数似然,等价于最小化后面那项平方和,因为前面那一堆都是和参数无关的常数:

[ \min_{\mathbf{w}, b} \sum_{i=1}^{n} (y_i - \mathbf{w}^T\mathbf{x}_i - b)^2 ]

看到了吗?这就是最小二乘。

这条推导建议你亲手抄一遍,因为它打通了一个更通用的认知:很多模型的损失函数,本质上都可以从“概率假设 + 极大似然”这个框架里推出来。比如逻辑回归用交叉熵而不是平方误差,就是因为分类问题里的输出服从伯努利分布,而不是正态分布。你以后看到一个新模型的损失函数时,不会再觉得它是“拍脑袋想出来的”,而是会下意识想问一句:这个模型假设了什么分布?

3. 正规方程推导:从一元斜率达到矩阵闭式解

3.1 一元线性回归的手推过程

先从最简单的一元情况开始。假设只有一个特征 (x),模型是:

[ \hat{y}_i = w x_i + b ]

损失函数为:

[ S(w, b) = \sum_{i=1}^{n} (y_i - w x_i - b)^2 ]

我们的目标是找到让 (S) 最小的 (w) 和 (b)。因为 (S) 是关于 (w, b) 的凸二次函数,最小值处偏导数一定为 0。分别求偏导:

[ \frac{\partial S}{\partial b} = -2 \sum_{i=1}^{n} (y_i - w x_i - b) = 0 ]

[ \frac{\partial S}{\partial w} = -2 \sum_{i=1}^{n} x_i (y_i - w x_i - b) = 0 ]

由第一个式子整理得:

[ nb = \sum_{i=1}^{n} y_i - w \sum_{i=1}^{n} x_i ]

[ b = \bar{y} - w \bar{x} ]

这说明回归直线一定经过样本均值点 ((\bar{x}, \bar{y})),这是一个非常好用的直觉。

把 (b) 代入第二个式子,经过几步代数整理,可以得到:

[ w = \frac{\sum_{i=1}^{n} (x_i - \bar{x})(y_i - \bar{y})}{\sum_{i=1}^{n} (x_i - \bar{x})^2} ]

分子是 (x) 和 (y) 的协方差,分母是 (x) 的方差。所以斜率的本质是“x 每变化一个标准差,y 跟着变化多少”,这比“背公式”要有意思得多。理解了这一点,后续你看很多统计报表里的回归系数时,就能直接读出变量之间的关系强度。

3.2 用矩阵语言写出多维解

一元推导虽然直观,但实际项目里特征往往不止一个。这时候再用偏导一个一个推就不现实了,矩阵是更优雅的武器。

把截距 (b) 吸收进权重向量,方法是给设计矩阵 (X) 加一列全 1。假设 (X) 是 (n \times (p+1)) 的矩阵,第 1 列全为 1,后面每列对应一个特征,(y) 是 (n) 维列向量,(\mathbf{w}) 是 (p+1) 维列向量。那么预测值和真实值的残差向量是:

[ y - X\mathbf{w} ]

损失函数写成矩阵形式:

[ J(\mathbf{w}) = (y - X\mathbf{w})^T (y - X\mathbf{w}) ]

展开得:

[ J(\mathbf{w}) = y^T y - 2 \mathbf{w}^T X^T y + \mathbf{w}^T X^T X \mathbf{w} ]

现在对 (\mathbf{w}) 求梯度。这里需要用到两条矩阵求导法则:

[ \frac{\partial (\mathbf{w}^T \mathbf{a})}{\partial \mathbf{w}} = \mathbf{a} ]

[ \frac{\partial (\mathbf{w}^T A \mathbf{w})}{\partial \mathbf{w}} = (A + A^T)\mathbf{w} ]

注意 (X^T X) 是对称矩阵,所以第二条可化简为 (2X^T X \mathbf{w})。代入得到:

[ \frac{\partial J}{\partial \mathbf{w}} = -2X^T y + 2X^T X \mathbf{w} ]

令梯度等于 0:

[ X^T X \mathbf{w} = X^T y ]

[ \mathbf{w} = (X^T X)^{-1} X^T y ]

这就是著名的正规方程(Normal Equation),也叫最小二乘的闭式解。因为它是直接一步算出来的,不涉及任何迭代过程,所以只要 (X^T X) 可逆,这个解就是全局最优解,不需要纠结学习率、迭代次数这些事。

3.3 不可逆怎么办:伪逆与数值方法的出场

正规方程看起来美妙,但 (X^T X) 不一定可逆。最常见的情况有两种。第一种是特征之间存在完全的多重共线性,比如一个特征是“房子总面积”,另一个特征是“卧室面积”,它们高度相关,导致矩阵接近奇异。第二种是样本数小于特征数,比如做医学研究时只有 50 个病人却有 200 个基因指标,(X^T X) 是 200×200 的矩阵,但实际秩最多只有 50,必然不可逆。

遇到这种情况,数学上可以用伪逆(Moore-Penrose Pseudoinverse):

[ \mathbf{w} = X^{+} y ]

Python 里np.linalg.pinv(X)就能算伪逆,或者直接np.linalg.lstsq(X, y, rcond=None)求解。伪逆不要求 (X^T X) 可逆,它会给出一个最小二乘意义下的解。但从另一个角度看,不可逆本身往往是在提醒你:数据有问题,需要做特征筛选或正则化。单纯的伪逆能算出个结果,却不解决共线性背后的解释性问题。这一点到第 6 章讲多重共线性时再展开。

4. 梯度下降:那套你迟早要懂的迭代公式

4.1 正规方程的工程极限在哪里

闭式解既然能一步到位,为什么还要学梯度下降?因为正规方程在实际工程中有几个硬伤。

第一个硬伤是计算复杂度。计算 ((X^T X)^{-1}) 需要对一个 (p \times p) 矩阵求逆,时间复杂度大约是 (O(p^3))。当特征数 (p) 达到几万、几十万时,这一步基本跑不动。而梯度下降每一步的计算量大约是 (O(np)),迭代几百上千次通常就能收敛,性价比非常高。

第二个硬伤是内存和可扩展性。正规方程要把整个 (X) 矩阵装进内存,遇到海量数据直接内存爆炸。梯度下降则天然支持分批训练,每次只拿一小批样本算梯度,内存占用小得多,这也是深度学习训练几乎全靠梯度下降类算法的原因。

第三个硬伤是模型扩展。闭式解只适用于损失函数是凸二次函数的情况。一旦换成带 L1 正则的 Lasso、带非线性变换的神经网络,损失函数变得复杂,解析解就不存在了,只能依赖迭代优化。

说句题外话,很多同学备考时容易把正规方程和梯度下降对立起来,其实它们是互补的:数据规模小、特征少、(X^T X) 可逆时,优先用正规方程;数据规模大、特征多或模型复杂时,用梯度下降。没有谁绝对更好,只有合不合适。

4.2 梯度公式推导与更新规则

梯度下降的核心思想特别简单,可以拿“下山”来类比:你站在山腰上,想走到山谷,最快的方式是沿着当前最陡的方向往下走一步,然后重新判断方向,再走一步,反复直到到达山谷。这里的“山”就是损失函数曲面,“最陡方向”就是负梯度方向。

对我们最常用的损失函数:

[ J(\mathbf{w}) = \frac{1}{n} \sum_{i=1}^{n} (y_i - X_i \mathbf{w})^2 ]

前面已经推导过,它的梯度是:

[ \nabla J(\mathbf{w}) = \frac{2}{n} X^T (X\mathbf{w} - y) ]

于是参数更新规则就是:

[ \mathbf{w} \leftarrow \mathbf{w} - \eta \nabla J(\mathbf{w}) ]

其中 (\eta) 是学习率,控制每次迈多大步子。这个公式看起来简单,但每个符号的来龙去脉都值得想清楚:(X^T (X\mathbf{w} - y)) 本质上是把“每个样本的预测误差”重新映射回特征空间,用它来指导每个参数往哪个方向调。误差为正的样本,对应的参数会往一个方向推;误差为负的样本,会往反方向推,综合起来就是梯度。

配套的伪代码是:

初始化 w = 0 重复 steps 次: pred = X @ w error = pred - y grad = (2 / n) * (X.T @ error) w -= lr * grad

在实际工程里,我们通常还会记录每一轮的损失值,画一条 loss 曲线,用来判断是否收敛。

4.3 学习率、特征缩放、三种梯度下降的取舍

学习率是梯度下降里最需要经验调参的地方。我直接给一张实践速查表:

现象可能原因处理方式
loss 发生震荡或变成 NaN学习率过大调小学习率,从 0.001/0.01 开始试
loss 一直在缓慢下降但速度很慢学习率过小适当增大学习率或增加迭代次数
不同特征量纲差异大,loss 等高线呈扁长椭圆,迭代路径呈锯齿状特征未标准化对特征做 Z-score 标准化
loss 曲线看起来还在明显下坡迭代次数不够增大 steps 或设置早停条件

特征缩放这件事值得多说一句。如果两个特征量纲差一个数量级,比如“房屋面积”是几十到几百,“卧室数量”是 1 到 10,那么损失函数的等高线会被拉成极长的椭圆。梯度下降在这种地形里会来回震荡,收敛极慢。解决办法是对每个特征做标准化:

[ z = \frac{x - \mu}{\sigma} ]

标准化之后,每个特征均值为 0、方差为 1,损失函数的地形更接近等高的圆,梯度下降路径又直又快。代价是回归系数的解释性会变差,因为此时每个系数衡量的不再是“原始单位变化”的影响,而是“标准差变化”的影响。你要是在做需要可解释性的分析,要么保留原始特征,要么记得在解释时换算回去。

梯度下降还有一个家族之分:每次用全量数据算梯度的叫批量梯度下降(BGD);每次随机抽一个样本更新的叫随机梯度下降(SGD);每次抽一小批、比如 32 或 128 个样本的,叫小批量梯度下降(Mini-batch GD)。三者没有对错,只有场景。数据量小、特征少,BGD 稳定简单;数据量巨大、追求速度,SGD 或 Mini-batch 更实际;深度学习训练基本全是 Mini-batch。

5. Python 从零手写线性回归:不调库也要能跑

5.1 构造一份可复现的数据,并设计实验流程

理论讲完,是时候写代码了。这一节我会用 numpy 手写两种版本的线性回归:一种用正规方程的闭式解,一种用梯度下降迭代。

首先造一份能复现的模拟数据。为了贴合实际场景,我模拟“营销费用与销售额”的关系:花费 (x) 从 0 到 10 均匀分布,真实销售额满足 (y = 3x + 2),再加上标准差为 1.2 的高斯噪声。

import numpy as np # 固定随机种子,保证结果可复现 rng = np.random.default_rng(42) # 生成 200 个样本 X = np.linspace(0, 10, 200).reshape(-1, 1) true_w = 3.0 true_b = 2.0 y = true_w * X.ravel() + true_b + rng.normal(0, 1.2, size=X.shape[0]) # 划分训练集和测试集 indices = np.arange(len(X)) rng.shuffle(indices) # 打乱顺序,避免排序带来偏差 train_idx = indices[:160] test_idx = indices[160:] X_train, y_train = X[train_idx], y[train_idx] X_test, y_test = X[test_idx], y[test_idx]

注意几个细节。第一,一定要固定随机种子,否则你每次运行得到的数据不同,后面调参时根本没法对比。第二,数据要先打乱再划分,如果直接用前 80% 当训练集,测试集恰好落在某个区间,结果会有误导性。第三,这里我只造了一个特征,方便画图和理解;换成多个特征,原理完全一样,只是无法可视化。

5.2 先用正规方程实现闭式解版本

正规方程的实现非常简短,核心就是给 (X) 加一列全 1,然后套公式 (\mathbf{w} = (X^T X)^{-1} X^T y)。

def normal_equation(X, y): # 给 X 左边加一列 1,这一列对应截距项 b X_b = np.c_[np.ones((X.shape[0], 1)), X] # 核心公式 theta = np.linalg.inv(X_b.T @ X_b) @ X_b.T @ y return theta theta_ne = normal_equation(X_train, y_train) print("正规方程结果:", theta_ne) # 输出大概在 [2.0 左右, 3.0 左右]

theta_ne[0]是截距,theta_ne[1]是斜率。因为真实参数是 (b=2.0, w=3.0),只要噪声不算离谱,结果会和真实值非常接近。

这段代码一共三行核心逻辑,但它把数学推导完整落地了。你可以试试去掉np.c_那行,看看模型是不是会强行穿过原点,结果差很多——这就是截距项的作用。新手往往容易漏掉这一步,直接拿原始特征矩阵去算,导致拟合效果很差。

5.3 梯度下降版本:同一套数据,看它怎么收敛

接下来是梯度下降版。为了让收敛过程可观察,我把每一步的损失都记录下来。

def gradient_descent(X, y, lr=0.02, epochs=1000): X_b = np.c_[np.ones((X.shape[0], 1)), X] n = len(y) theta = np.zeros(X_b.shape[1]) # 从零向量开始 losses = [] for _ in range(epochs): pred = X_b @ theta error = pred - y # 梯度公式:2/n * X^T @ (Xw - y) grad = (2 / n) * (X_b.T @ error) theta -= lr * grad loss = (error ** 2).mean() losses.append(loss) return theta, losses theta_gd, losses = gradient_descent(X_train, y_train, lr=0.02, epochs=1000) print("梯度下降结果:", theta_gd)

运行之后你会发现,梯度下降学到的 (b) 和 (w) 与正规方程几乎一致,说明两种路子殊途同归。然后可以画一下 loss 曲线:

import matplotlib.pyplot as plt plt.plot(losses) plt.xlabel("Iteration") plt.ylabel("MSE Loss") plt.title("Gradient Descent Convergence") plt.show()

如果 curve 是平滑下降并趋于一条水平线,说明学习率和迭代次数选得合适。如果看到 loss 先降后升像波浪一样,多半是学习率偏大;如果 1000 轮之后 loss 还在明显下降,说明迭代次数不够。

我自己调试时第一次跑就踩过坑:直接把学习率设成 0.5,结果 loss 直接冲到了 NaN。原因很简单,梯度下降每一步都在跨越“山谷”的谷底,步子太大反而跳出到更高的地方,最终发散。所以我的习惯是,面对新数据永远从 0.01 这类保守值开始试,再一点点往上加。

5.4 和 sklearn 的 LinearRegression 对照一次

手写实现验完原理之后,再用 sklearn 对照一下,确保我们的“自制版”没有改错公式。

from sklearn.linear_model import LinearRegression model = LinearRegression() model.fit(X_train, y_train) print("sklearn 截距:", model.intercept_) print("sklearn 系数:", model.coef_) # 计算 R² def r2_score(y_true, y_pred): ss_res = ((y_true - y_pred) ** 2).sum() ss_tot = ((y_true - y_true.mean()) ** 2).sum() return 1 - ss_res / ss_tot # 用测试集评估 y_pred_ne = np.c_[np.ones((X_test.shape[0], 1)), X_test] @ theta_ne y_pred_gd = np.c_[np.ones((X_test.shape[0], 1)), X_test] @ theta_gd y_pred_sk = model.predict(X_test) print("正规方程 R²:", r2_score(y_test, y_pred_ne)) print("梯度下降 R²:", r2_score(y_test, y_pred_gd)) print("sklearn R²:", r2_score(y_test, y_pred_sk))

三次输出的 (R^2) 应该非常接近。这说明自己实现的逻辑没有错,sklearn 的LinearRegression底层用的也是最小二乘的思路,只不过做了更多数值稳定性优化。

那为什么还要自己写一遍?很简单:生产环境当然用 sklearn,又快又稳;但学习阶段如果不亲自写一遍正规方程和梯度下降,你对“参数到底怎么来的”就永远是黑箱。后面你换损失函数、加正则项时,能不能改明白代码,就在这上面见真章。

6. 拟合之后,四件诊断让模型不死得不明不白

6.1 别迷信 R²:新增无用特征只会让它变大

模型拟合完,大家第一反应都是看 (R^2)。(R^2) 的定义是:

[ R^2 = 1 - \frac{\sum (y_i - \hat{y}_i)^2}{\sum (y_i - \bar{y})^2} ]

它表示模型解释了总方差里百分之多少的变化。(R^2 = 0.95) 听起来很美,但它有一个陷阱:你往模型里塞多少特征,(R^2) 都只会增不会减,哪怕塞进去的是一个纯随机噪声列。因为最小二乘总能找到一组系数来利用这种噪声,哪怕只是微弱的、虚假的相关。

所以要用调整后的 (R^2)(Adjusted R²),它会对特征数量做出惩罚:

[ R^2_{\text{adj}} = 1 - (1 - R^2) \cdot \frac{n - 1}{n - p - 1} ]

(p) 是特征数量。当你新增一个几乎没用的特征时,(R^2) 本身可能涨一点点,但 (\frac{n-1}{n-p-1}) 这个惩罚项会让调整 (R^2) 反而下降。这也是为什么在特征筛选时,调整 (R^2) 比普通 (R^2) 靠谱得多。

6.2 残差图:模型用没用对的照妖镜

(R^2) 告诉你“拟合得好不好”,残差图告诉你“模型是不是被用对了”。所谓残差,就是真实值减预测值:

[ \text{residual}_i = y_i - \hat{y}_i ]

理想的残差应该是在 0 附近随机分布的,没有趋势、没有形状。你把残差画在纵轴上、预测值画在横轴上,如果看到一团均匀的“云”,说明模型基本正确。如果看到漏斗形,预测值越大的地方残差波动越大,说明存在异方差性;如果看到 U 形或倒 U 形,说明数据里有明显的非线性关系,你该考虑增加多项式特征;如果残差和某个特定特征仍然相关,说明你可能漏掉了交互项。

这种诊断在线性回归里特别重要,因为最小二乘的很多统计性质(比如系数标准误的估计)都建立在残差独立同分布且同方差的假设上。残差图不满足,即使 (R^2) 很高,参数的置信区间也可能失真。

6.3 多重共线性:系数符号反了是常有的事

多重共线性是“看着预测准但实际上很困惑”的典型问题。假设你的特征里同时有“房屋总面积”和“卧室面积”,两者高度相关,那么模型在分配权重时会有无数种组合都差不多好用。你拿到的系数可能是一个正、一个负,可实际上卧室面积多的房子确实更贵,符号为负显然违背直觉。

检测共线性最常用的指标是方差膨胀因子(VIF)。简单理解,就是用当前特征做因变量、其他特征做自变量的回归,看它的 (R^2),然后:

[ VIF = \frac{1}{1 - R^2} ]

VIF 超过 10 就被认为需要警惕。处理办法也很直接:删除其中一个高度相关的特征;或者做 PCA 降维后再回归;再或者改用岭回归(L2 正则)来稳定系数。注意,如果你只是想做预测,不太关心单个系数的解释,那共线性造成的伤害有限。但如果公司领导问你“为什么面积越大反而房价越低”,你就得先查一查 VIF。

6.4 离群点的杀伤力与解决思路

最小二乘对离群点非常敏感,因为平方项会给大误差极高的权重。一个极端离群点,哪怕只有一个,也可能把整条回归线“拖”向自己,让大部分正常样本的拟合变差。

处理离群点的思路分几层。先看数据是不是录入错误,比如把 100 万录成了 10 万,这种直接修;然后是统计上的判断,比如用箱线图或 Z-score 找出那些超出合理界限的样本,结合业务场景决定是否剔除;再往上是采用更鲁棒的损失函数,比如用绝对值损失(MAE)代替平方损失,或者使用sklearn里的HuberRegressor,它综合了平方损失和绝对值损失的特点,对离群点更加宽容。

我个人的建议是,不要一看到离群点就删。数据科学里,离群点可能是异常值,也可能是重要信号——比如欺诈交易、故障设备。你需要先理解它为什么离群,再决定是修正、剔除,还是换鲁棒算法。

7. 我在实际项目里用线性回归攒下的几点经验

最后分享几条踩过坑之后才真正理解的经验。

第一,线性回归的“可解释性”是它最大的竞争力。很多时候企业不会只关心预测“准不准”,还会关心“为什么是这个值”。同样是预测员工离职倾向或者分析营销效果,一个精度略低但系数含义清晰的线性模型,往往比一个黑箱的复杂模型更受业务部门信任。所以不要觉得线性回归“太简单”就不屑一顾,它在金融风控、医学、经济学这些强调解释的场景里仍然大量使用。

第二,特征是决定模型上限的东西。线性回归本身很简单,真正拉开差距的是特征工程。把你对业务的理解变成特征,比如把时间戳拆成“是否周末”“是否节假日”,把连续值切分成分段变量,这些操作经常能让一个朴素线性模型的性能大幅提升。我之前做某个销售预测项目,辛辛苦苦调模型参数学到手都没有明显提升,后来发现是少了一个“是否为促销日”的特征,加上之后模型表现立刻上了一个台阶。

第三,梯度下降的学习率不要想当然。新数据新场景下,第一次跑训练时一定记得把 loss 曲线打出来看一眼。它能告诉你学习率设大了还是小了,迭代次数够不够。我见过太多人闷头跑模型,结果 loss 一直发散成 NaN 还在问为什么,原因就是没养成看损失曲线的习惯。

线性回归虽然是最基础的机器学习算法,但它背后的思想——损失函数怎么写、闭式解和迭代解怎么选、模型诊断怎么做——会伴随你整个机器学习生涯。把这一套真正搞懂,后面再学逻辑回归、岭回归、Lasso、神经网络,你会发现很多“新知识”不过是这里改一点、那里换一个假设而已。

返回列表