1. 线性回归在解决什么问题:一张表、一条线、一个预测
1.1 回归问题的本质与场景
凡是预测连续数值的问题,基本都可以归到回归问题这一类。比如预测明天的气温、预测二手车的挂牌价、预测电商一波促销带来的订单量、预测坐车去机场的用时,底子都是同一个套路:拿一堆影响因素作为特征,去寻找它们和目标值之间的数量关系。
线性回归是这套套路里最朴素、也最常用的一个模型。它假设目标值和特征之间能用一条直线(二维空间)或者一个超平面(高维空间)来描述。这个假设当然不是每条数据都满足,但它有两个非常大的好处:第一,模型足够简单,计算量小,出活快;第二,参数有明确含义,能解释成“某个特征变化一单位,目标平均变化多少”。
也正是因为这两点,不管后面你学的是决策树、随机森林还是神经网络,线性回归都值得作为第一个被认真吃透的模型。它虽然不是最花哨的算法,却是建立“数据、特征、目标、误差”这个基本思考框架的起点。
1.2 模型形式与截距的“小把戏”
用数学写出来就是:
[ y = w_1 x_1 + w_2 x_2 + \cdots + w_d x_d + b ]
其中 (b) 是截距,也就是当所有特征都是 0 时的基准预测值。你可以把 (b) 看成另一个可训练参数 (w_0),对应一列永远等于 1 的特征,这样模型就能写成一个更紧凑的矩阵形式:
[ \hat{y} = Xw ]
(X) 是 (n) 行 (d+1) 列的矩阵,左侧第一列是 1,后面是各个特征;(w) 是 (d+1) 维向量。
为什么要这么写?因为一旦转成矩阵形式,参数求解就能用线性代数工具一步到位。后面讲正规方程时,你会看到这个写法的威力。我第一次学的时候没想通这一步,单纯觉得是为了装酷,后来才发现几乎所有机器学习推导都是建立在矩阵基础上的,绕开它反而吃亏。
1.3 “回归”这个名称的来历,以及与分类的区分
很多时候你会听到“回归”和“分类”并列出现,初学者容易搞混。一个直观的区分方法:连续的是回归,离散的是分类。预测房价是回归,识别图片里是猫还是狗是分类。预测明天降水概率其实是回归,因为它输出 0 到 1 之间的连续数值;把概率再划成“下”或“不下”,才变成分类。
至于“回归”这个词本身,它来自早期统计学。有人发现,高个子父母的后代身高平均不减,但很少像父母那样高得极端;矮个子父母的后代也类似,会向人群均值靠拢。这种现象被称为“均值回归”。后来这个词被延伸为用一组变量去估计另一组变量的建模过程,和最早那个具体现象其实已经没有太大关系了。
知道了这段来由,你再听人说“线性回归”时,就不会把它当成一个玄学名词,而是可以自然理解成:我要找一条线,让它在预测某个连续值这件事上尽量靠谱。
2. 最小二乘的前因后果:为什么是平方误差,以及怎么求参数
2.1 用平方误差的合理性
定义一个模型好不好,必须先定义误差。最简单的是直接误差:真实值减预测值。但正负误差会互相抵消,所以不能用平均水平来代表“总误差”。于是自然会想到误差的绝对值,或者误差的平方。
为什么不选绝对值?绝对值函数在 0 点不可导,损失函数带绝对值导不流畅,数学优化上很别扭。更关键的是,平方误差的导数与误差大小成正比,意味着模型对预测得离谱的样本会施加更大的惩罚,这样训练时更不敢轻易放飞自我。
这里还有一个统计层面的解释:如果假设误差服从均值为 0 的正态分布,那么在训练样本上做最大似然估计,最终目标函数会变成最小化误差平方和。也就是说,选择平方误差并不只是一个数学技巧,它背后隐含着“误差大致呈正态分布”这个假设。一旦这个假设崩溃,平方误差的表现就会变差,后面我们会用残差图来检查这一点。
2.2 正规方程:从求导到闭式解
在矩阵形式下,损失函数是:
[ L(w) = |y - Xw|^2 ]
展开后,对 (w) 求梯度并令梯度为 0,就能得到:
[ \hat{w} = (X^T X)^{-1}X^T y ]
这个结果就是正规方程。理解它只需要三步:先展开损失函数,再对 (w) 求导,最后化简移项。我见过很多教程直接甩公式,导致初学者背得很痛苦。实际上如果你自己手动推一遍,就会发现它和一元二次函数找最小值的方法一模一样。
但这个公式有个使用前提:(X^T X) 必须可逆。什么时候不可逆?最典型的情况是有两个特征完全线性相关,比如“面积”和“面积+100”,这时候矩阵秩不够,方程组无唯一解。另外一种情况是样本数比特征数还少。遇到这种情况,正统做法是换成 SVD 分解求解,或者加一个很小的惩罚项——这个惩罚项其实就是后面要讲的岭回归。
正规方程在数据量小的时候非常香,一次矩阵运算即可得到精确解。但数据量一大,计算 (X^T X) 的复杂度是 (O(nd^2)),求逆还要再花 (O(d^3)),如果 (d) 有几千甚至几万,等它算完你都能在旁边喝两杯咖啡了。所以在大规模场景里,我们更常听到的是梯度下降。
2.3 梯度下降:另一种迭代路线
梯度下降的基本想法是从一个初始参数出发,沿着损失下降最快的方向反复更新。更新式是:
[ w_{\text{new}} = w_{\text{old}} - \text{lr} \cdot \text{gradient} ]
这里的 gradient 是损失函数对参数的梯度,lr 是学习率。学习率太大,参数会在最优值附近震荡;太小则半天走不动。在理想情况下,梯度等于 0 时训练就收敛了。
在实践中还有三个变体:批量梯度下降用全量数据算梯度,最稳定但慢;随机梯度下降每次只用一条样本,快但有噪音;小批量梯度下降一次用一小批样本,是现实中的折中。线性回归那边的梯度刚好有闭式表达:
[ \text{gradient} = X^T(y - Xw) ]
写起来比神经网络简单很多,非常适合用来理解迭代优化的框架。
我第一次写梯度下降时还犯了个低级错误:忘记把梯度除以样本数。结果训练曲线看起来非常合理,但系数怎么都不对,后来才发现是学习率被样本数放大了。这种经验不在书里写,只有自己动手踩过才有记忆。
3. 从零实现到调包对比:一次完整的线性回归试做
3.1 构造一份可以复现的模拟数据
为了不让理论悬在空里,我先用 numpy 造了一份数据,让每个读者都能在自己电脑上复现,零依赖跑通。设置随机种子、300 个样本、两个特征,真实参数是 (w=[2.0, -0.5]),截距 (b=3.0),并加上标准差 0.5 的噪声:
import numpy as np from sklearn.model_selection import train_test_split np.random.seed(42) X = np.random.randn(300, 2) true_w = np.array([2.0, -0.5]) true_b = 3.0 y = X @ true_w + true_b + 0.5 * np.random.randn(300) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=0 )这样生成的 (y) 和 (X) 之间有一个确定的线性关系,你心里知道真值,正好用来检验不同算法能不能找回这个真值。
3.2 用 numpy 手写最小二乘和梯度下降
先写一个正规方程类,代码很短:
class NormalEquation: def fit(self, X, y): Xb = np.hstack([np.ones((X.shape[0], 1)), X]) self.w = np.linalg.inv(Xb.T @ Xb) @ Xb.T @ y def predict(self, X): Xb = np.hstack([np.ones((X.shape[0], 1)), X]) return Xb @ self.w再看一个纯朴的梯度下降:
class GDRegressor: def __init__(self, lr=0.1, epochs=100): self.lr = lr self.epochs = epochs def fit(self, X, y): Xb = np.hstack([np.ones((X.shape[0], 1)), X]) n, d = Xb.shape self.w = np.zeros(d) for _ in range(self.epochs): grad = (Xb.T @ (Xb @ self.w - y)) / n self.w -= self.lr * grad def predict(self, X): Xb = np.hstack([np.ones((X.shape[0], 1)), X]) return Xb @ self.w注意梯度里我除以了 (n),这样才能保证学习率不受样本量影响。
然后用一份测试集计算 (R^2),定义很直观:
def r2_score(y_true, y_pred): ss_res = np.sum((y_true - y_pred) ** 2) ss_tot = np.sum((y_true - np.mean(y_true)) ** 2) return 1 - ss_res / ss_tot在这份数据上,正规方程跑出来的系数大概是截距 3.01、(w_1=2.02)、(w_2=-0.49),测试 (R^2) 约 0.94;梯度下降达到相近值时,主要受学习率和收敛轮数影响。两者都在找同一个最小二乘解,得到相似结果其实符合预期。
3.3 和成熟库的结果对比
把同一个数据集丢给 sklearn 的线性回归,系数和 (R^2) 几乎完全一致。不过成熟库内部不是硬套正规方程,而是用了 SVD 分解。SVD 的好处是:哪怕 (X^T X) 不可逆,也不会直接报错,而是给出一个最小二乘意义下的解,数值上更稳定。
我的建议是:如果你在做项目,直接调库就行,省时省力;但如果你在学原理,手写一遍正规方程和梯度下降是不可替代的。跑通这两段代码的收获,可能比刷十道练习题更有用。
3.4 特征归一化:对两种求解方式完全不同的影响
这里有个很多书里没讲清楚的点:正规方程对特征尺度不敏感。因为它是闭式解,算的就是最优参数;无论特征是米还是毫米,模型都能从数学上给出相应的系数。但梯度下降对尺度非常敏感。
想象一个二维损失函数,(x_1) 范围在 0 到 1 之间,(x_2) 范围在 0 到 10000 之间,损失函数的等高线会被压成细长的椭圆。梯度在这些椭圆之间来回横跳,就像在走 Z 字形;归一化后等高线接近圆形,每一步都能直接指向圆心。
因此使用梯度下降前,最好把每个特征标准化为均值 0、方差 1。代码上就是先做 StandardScaler,注意测试集的 scaler 要在训练集上 fit 后再 transform,防止信息泄漏。很多人一开始在整体数据上 fit scaler,后面模型评估虚高,这是特别容易踩的坑。
4. 模型诊断比训练更重要:R²、残差图与特征变换
4.1 R²与调整R²怎么读
(R^2) 的定义是 1 减残差平方和除以总平方和,可以理解为模型解释掉的方差比例。(R^2) 等于 0.9 代表 90% 的波动被模型解释了。但必须记住:(R^2) 是一个相对指标,不是绝对指标。对同一份数据,(R^2) 越高通常模型越好;对不同数据,(R^2) 没法直接比较,因为数据的噪声水平完全不同。
还有一个魔鬼细节:往模型里加任何特征,(R^2) 都不可能下降,它只会持平或上升,因为最小二乘总能找到至少不更差的解。这就催生了调整 (R^2):它把特征数量作为惩罚因子,每多一个特征都会抵消一部分 (R^2) 收益。所以在你评估“加这个特征到底有没有用”时,看调整 (R^2) 比看 (R^2) 更可信。
我在项目里更常使用的指标是 RMSE,因为它和原始目标量纲一致,可以直接告诉业务方“平均预测偏差是多少”。(R^2) 展示解释力,RMSE 展示误差大小,两个配合起来用才完整。
4.2 残差图的三把尺子
(R^2) 只是“一口价”,残差图才是真正能看清模型毛病的工具。残差等于真实值减预测值。画散点图,纵轴是残差,横轴是预测值,理想情况是所有点随机散布在 0 线上下,像一个均匀的云团。
如果看到喇叭形,也就是预测值越大、残差离散程度越大,这是异方差。解决办法可以先对 (y) 取 log,把大值范围的差距压缩一下。如果看到残差呈现出明显的弯曲,比如先正后负再正,说明模型缺了非线性结构,常见对策是加平方项、交互项,或者换更灵活的模型。如果看到残差点几乎都在 0 线上方或下方,那是模型存在系统性偏差,先检查是否有截距,再检查数据刻度单位是否弄错了。
有一次我在某次练习中残差图总是一条斜线,折腾半天发现“目标值”那一列包含了拼接错误,部分行把面积当成房价写进去了。图这种东西,你盯久了真的能看出数据本身的怪味。
4.3 特征变换与多项式特征的一次补救
线性回归的“线性”指的是参数线性,不是特征线性。你可以放心地往特征列表里塞平方项、开方项、对数项,只要参数还是线性的,它依然是广义的线性回归。
我做了个实验:构造一份明显含交互效应的数据 (y = 1.2x_1 - 0.5x_2 + 0.8x_1x_2 + \text{noise})。先用纯线性模型拟合,残差图出现对称的弯曲,怎么看都不顺眼;加上 (x_1x_2) 这一列再拟合,弯曲消失,(R^2) 直接从 0.6 左右跳到了 0.9 以上。这个对比非常直观地说明了特征工程在模型边界内的威力。
在实际项目中,如果你业务上能判断出两个变量会共同影响目标,比如广告投入和渠道评级会互相放大效果,就应该主动生成交互特征,而不是等模型自己发现——线性模型本来就没这个能力。
5. 一份模拟房价数据的完整试做案例
5.1 数据探索与清洗
这次我构造了一份 2000 条左右的模拟二手房数据,含五个特征:面积、卧室数、房龄、周边评分、是否精装。目标价格由这些特征线性组合加噪声生成,并且我故意在其中埋了几个脏值:面积等于 0 的记录、房龄为负的记录、少数价格异常大的离群点。
第一步一定是看数据摘要:describe、isnull、duplicated。看到面积最小值为 0 时,不要直接全部删除,先想想 0 是缺测还是录入错误,用中位数填充还是剔除。我在这里选择把面积小于 5 的样本删掉,因为“房龄负数”更像年份字段被写错,用该列中位数替换更合理。
这类清洗动作的每一步都要记录下来,写清楚为什么删、为什么填。项目评审时别人问你“为什么样本数从 2000 变到 1973”,你能给出可解释的答案,这会让整个模型的可信度上一个台阶。
5.2 特征编码与建立模型
“是否精装”是 0/1 标签,直接映射。像“朝向”这种多分类变量,则需要独热编码。独热编码很容易制造多个相关列,必须设置 drop_first 删除其中一个,否则会引入共线性。关于这一点,不做的时候只是知道,做了之后看到系数矩阵变成奇异,才真正理解“虚拟变量陷阱”。
然后照例划分训练集和测试集,比例 8:2。StandardScaler 要先用训练集 fit,再到测试集 transform,注意千万别在切分前对整个数据做归一化。我用 LinearRegression 拟合后,测试集上的 (R^2) 大约是 0.71。对一个带不少噪声的模拟数据来说,这个表现说明特征选择的方向是对的。
5.3 系数解释与验证
得到的系数中,面积系数约 2.13,房龄系数约 -0.42。在原始尺度下解释时,需要保证口径一致。假设面积单位为平方米,价格单位为万元,那结论就是:其他条件不变时,面积每增加一平方米,价格平均增加 2.13 万元;房龄每增加一年,价格平均减少 0.42 万元。
这种口头解释很容易被业务方接受,也是线性回归的主要卖点。不过要真诚地加一句警示:当特征相关时,单个系数的符号和大小可能不稳。面积和卧室数高度相关,模型中卧室数系数甚至有可能变成负的,这不是说卧室多会让房子贬值,而是“面积”把相关信息拿走以后,剩余的边际信息本来就有限,很容易被噪声淹没。
5.4 试做过程中的几个实际槽点
我在这次试做里至少踩了三个坑,值得写出来。第一,价格单位混用:输入数据里有的行是“万元”,有的行是“元”,我不仅没统一,还直接跑了模型,导致系数和残差图全都乱了套。第二,测试集归一化泄漏:在全体数据上先做 StandardScaler 再切分,相当于模型“提前偷看”了测试集的统计量,预测效果虚高。正确顺序一定是先 split 再 fit scaler。
第三,训练时忘填截距:如果完全用标准化的特征训练,模型会强制过原点;很多场景下截距其实是残差最后的兜底项。建议永远让模型学截距(或把截距列加进去),然后观察截距是否显著非零,从而判断是否需要调整特征配方。
这些坑都不高级,但每一条都真实存在。把过程记录成笔记,价值不亚于模型本身。
6. 线性回归的边界预警:多重共线性、离群点与什么时候换模型
6.1 多重共线性为什么会让系数失控
如果两个特征强相关,比如面积和卧室数,(X^T X) 会变得接近奇异,求逆时会放大微小数值波动,最终系数的方差变得巨大。换句话说,模型可以“记得”训练集,但换一批数据系数可能完全变样。检测多重共线性常用方差膨胀因子 VIF,一般认为 VIF 大于 10 就有问题。
处理办法有几种:直接删除冗余特征、用 PCA 把相关特征压缩成不相关的成分、或者使用岭回归。我建议先从业务角度删特征,因为 PCA 之后模型可解释性会下降;如果没有业务约束,再用正则化兜底。
6.2 离群点与稳健回归
线性回归用平方误差,所以对离群点异常敏感。一个典型的极端案例:有一组大致沿对角线分布的点,只要横坐标较大的位置冒出一个偏离很远的点,拟合线就会被硬拽过去。这就是为什么先要画散点图或箱线图看数据。
如果离群点数量不多,且确认是录入错误,可以删除;但如果离群点来自真实分布,比如一些极端高价房屋,简单删除会丢失信息,更好的思路是使用 Huber 回归这样对离群值不敏感的稳健方法。它的损失函数在误差较小时是平方,超过阈值后变成线性,既保留效率又限制离群点的影响。
6.3 正则化:第一次救火
前面提到的岭回归,就是在最小二乘目标函数里加一个 L2 惩罚项 (\lambda|w|^2),让参数不能太大。这能有效对抗多重共线性带来的系数方差膨胀。更进一步的 Lasso 使用 L1 惩罚 (\lambda|w|_1),它会让部分系数被压缩到 0,等于在训练过程中自动做特征选择。
正则化系数的选取通常交给交叉验证完成。道理并不复杂:(\lambda) 越大,惩罚越强,模型越偏向简单;(\lambda) 太小,惩罚只是挠痒。试着画出 (R^2) 随 (\lambda) 的变化曲线,你会清楚看到过拟合到欠拟合的过渡过程。
6.4 基线模型思维与后续扩展
我个人工作习惯是:接到任何数值预测任务,第一步永远是跑一个最朴素的线性回归 baseline,记录 (R^2) 和 RMSE。不是因为我相信线性关系,而是我想知道数据里到底有多少可学习的信号。如果 baseline 的 (R^2) 已经很高,复杂模型提升空间有限,不值得增加复杂度;如果 baseline 很差,也该先看看是特征工程有问题,还是数据本身噪声太大,而不是盲目上一棵巨树。
后续扩展也有清晰路径:特征不够用,加多项式特征;误差分布不合适,换广义线性模型;非线性太强,再看支持向量回归、随机森林或梯度提升树。但不管走到哪一步,线性回归这块地基打得牢不牢,决定了你能不能在需要时回头解释“模型为什么这么预测”。
这次把线性回归从里到外试了一遍,我最大的收获不是会调参,而是终于理解了一个朴素的公式背后有那么多前提和细节。如果你也想把基础知识吃透,建议从手写代码和残差图开始,少看几个指标,多看几张图。