1. 为什么从多元线性回归开始讲起
如果你翻过任何一本机器学习的入门教材,大概率会发现回归问题总是被放在最前面。这不是巧合,也不是作者偷懒。回归是整个监督学习体系里最基础、最直观、也最容易建立直觉的一块内容。而多元线性回归,又是回归家族里最核心的那根支柱。
我见过不少朋友,一上来就去啃神经网络、集成学习,结果调参调得一头雾水,损失函数震荡得像心电图,最后回头补基础才发现,很多问题的根子其实在回归这一层就没打牢。比如梯度下降为什么有时候不收敛、特征为什么要做标准化、正则化到底在惩罚什么,这些问题在多元线性回归里全都能找到最朴素的答案。
这篇文章要聊的,就是多元线性回归。它解决的是一个非常现实的问题:当我们面对多个影响因素时,怎么用一个统一的模型去描述它们和目标值之间的关系。比如房价受面积、楼层、地段、房龄等多个因素影响,销售额受广告投入、季节、定价、竞品数量等多个因素影响。单个变量的一元线性回归在这些场景下明显不够用,多元线性回归就是用来处理这种"多对一"映射关系的。
适合谁来读?如果你刚接触机器学习,正在学回归部分,这篇文章可以帮你把概念、推导、代码实现和踩坑经验一次性串起来。如果你已经用过线性回归但只是调包,没仔细想过背后的原理,那正好可以借这个机会把底层逻辑补上。我会尽量用大白话把数学讲清楚,同时给出可以直接跑的代码和实操中真正会遇到的问题。
2. 多元线性回归到底在做什么
2.1 从一元到多元的思维跃迁
一元线性回归的公式大家应该都熟:y = wx + b。一个输入x,一个输出y,模型要学的就是斜率w和截距b。放到二维平面上,就是找一条直线去拟合散点。
多元线性回归无非是把输入从一个变成多个。公式变成这样:
y = w₁x₁ + w₂x₂ + ... + wₙxₙ + b
这里的x₁到xₙ是n个特征,w₁到wₙ是对应的权重,b还是截距。虽然写起来只是多了几个字母,但背后的几何意义变了。一元回归是在二维空间找一条线,多元回归是在高维空间找一个超平面。三维空间里是一个平面,四维以上就没法直观画出来了,但数学上的处理方式是一致的。
我习惯用一个类比来解释:一元回归像是根据"面积"这一个指标去猜房价,多元回归则是同时看面积、楼层、房龄、距离地铁远近等一堆指标综合判断。现实中显然后者更靠谱,因为房价从来不是单一因素决定的。
2.2 用矩阵形式统一表达
逐个写w₁x₁ + w₂x₂ + ... 太啰嗦,而且写代码的时候也不方便。所以实际中我们都会把它写成矩阵形式:
y = Xw + b
其中X是m×n的特征矩阵(m个样本,n个特征),w是n×1的权重向量,y是m×1的预测值向量。如果习惯的话,也可以把b吸收进w里,给X额外加一列全为1的特征,这样公式就简化成 y = Xw,更紧凑。
为什么一定要用矩阵形式?两个原因。第一,表达简洁,推导方便。第二,计算效率高。矩阵运算在底层可以用BLAS这类高度优化的库来加速,比写循环快几个数量级。你后面看sklearn或者PyTorch的源码,线性层的实现本质上就是这个矩阵乘法。
2.3 模型要学什么
模型里未知的就是那组权重w和截距b。训练的过程,就是找到一组最优的w和b,使得模型预测值尽可能接近真实值。那怎么衡量"接近"?这就引出了损失函数。
最常用的是均方误差(MSE):
L = (1/m) * Σ(yᵢ - ŷᵢ)²
其中yᵢ是真实值,ŷᵢ是预测值,m是样本数。这个损失函数对每个样本的误差取平方再求平均。取平方的好处是正负误差不会相互抵消,而且对大误差的惩罚更重。求平均是为了让损失值和样本数量无关,方便不同数据集之间比较。
注意:MSE对异常值非常敏感。因为误差被平方了,一个偏离很远的点会贡献巨大的损失值,把整个模型的参数往它那边拽。如果你的数据里有明显的异常点,要么先清洗,要么考虑用MAE(平均绝对误差)这类对异常值更鲁棒的损失函数。
3. 参数怎么求:两种主流解法
3.1 正规方程:一步到位的解析解
对于线性回归,损失函数是凸函数,存在唯一的全局最优解。我们可以直接对损失函数求导,令导数为零,解出w的闭式解。推导过程这里不展开写每一步,直接给结论:
w = (XᵀX)⁻¹Xᵀy
这就是著名的正规方程(Normal Equation)。它的优点是直接给出解析解,不需要迭代,不需要调学习率。对于特征数量比较少的数据集(比如n小于几千),用这个方法非常方便。
但它有几个明显的局限。第一,需要计算矩阵的逆,时间复杂度大约是O(n³),特征一多就非常慢。第二,XᵀX可能不可逆,比如特征之间存在完全共线性,或者样本数少于特征数。第三,数值稳定性不如迭代方法,当矩阵条件数很大时,求逆的结果误差会比较大。
实际中如果非要用正规方程,一般会用伪逆(pinv)来替代直接求逆,或者加一点正则化项让矩阵变得可逆。sklearn的LinearRegression默认用的就是类似正规方程的方法(底层是SVD分解),所以在中小规模数据上表现还不错。
3.2 梯度下降:迭代逼近的通用方法
梯度下降是更通用的做法。它的思路很朴素:先随机初始化一组参数,然后计算损失函数对每个参数的梯度,沿着梯度的反方向更新参数,一步步逼近最优解。
更新公式:
wⱼ := wⱼ - α * ∂L/∂wⱼ
其中α是学习率,控制每一步走多大。学习率太小,收敛慢;太大,可能直接跨过最优点甚至发散。
对于MSE损失,梯度的推导结果是:
∂L/∂wⱼ = (2/m) * Σ(yᵢ - ŷᵢ) * xᵢⱼ
这个梯度有一个很直观的解释:每个权重的更新量,取决于预测误差和对应特征值的乘积。如果某个特征和误差正相关,就减小这个特征的权重;如果负相关,就增大权重。
梯度下降又分三种变体,这个在实际操作中很关键:
| 类型 | 每次用多少样本 | 优点 | 缺点 |
|---|---|---|---|
| 批量梯度下降 | 全部样本 | 梯度准确,收敛稳定 | 数据量大时每步计算慢 |
| 随机梯度下降 | 1个样本 | 每步快,能跳出局部最优 | 梯度噪声大,收敛震荡 |
| 小批量梯度下降 | 一批样本 | 兼顾速度和稳定性 | 需要调batch size |
实际中几乎都用小批量梯度下降,batch size一般取32、64、128、256这些值。为什么?因为批量太大内存扛不住,太小又利用不了矩阵运算的并行优势。小批量刚好卡在中间,既快又稳。
3.3 两种方法的选型建议
我一般是这样判断的:特征数在几千以内,样本量也不算特别大,直接用正规方程或者sklearn的默认解法,省事。特征数上万,或者数据量很大需要分批加载,那就用梯度下降。另外,如果你后面要扩展到逻辑回归、神经网络这些没有解析解的模型,梯度下降是必须掌握的,早点熟悉没坏处。
实操心得:不要一上来就纠结用哪种方法。先用sklearn的LinearRegression跑一个baseline出来,看看效果。如果效果可以,再考虑要不要自己手写梯度下降去优化。很多时候baseline已经够用了,过度优化反而浪费时间。
4. 手写实现:从零搭建一个多元线性回归
4.1 数据准备与特征工程
我拿一个模拟数据集来演示。假设我们要预测某个商品的销量,特征包括广告投入、价格、季节指数三个变量。数据是我用numpy随机生成的,加了一些噪声,模拟真实场景。
import numpy as np import matplotlib.pyplot as plt np.random.seed(42) m = 200 # 样本数 n = 3 # 特征数 # 生成特征 X = np.random.randn(m, n) # 真实权重和截距 true_w = np.array([2.5, -1.8, 0.9]) true_b = 4.0 # 生成目标值,加噪声 y = X @ true_w + true_b + np.random.randn(m) * 0.5生成完数据后,有一件事必须做:特征标准化。为什么?因为不同特征的量纲可能差很多。比如广告投入可能是几十万,价格可能是几十块,季节指数在0到1之间。如果不做标准化,梯度下降会走得很别扭,因为不同维度的梯度尺度差异太大,学习率很难同时适配所有维度。
标准化的做法是每个特征减去均值再除以标准差:
X_mean = X.mean(axis=0) X_std = X.std(axis=0) X_norm = (X - X_mean) / X_std注意,测试集要用训练集的均值和标准差来标准化,不能用自己的。这个细节很多人会忽略,导致训练和推理时的数据分布不一致。
4.2 损失函数与梯度计算
损失函数用MSE,梯度按前面推导的公式来:
def compute_loss(X, y, w, b): m = len(y) y_pred = X @ w + b loss = np.mean((y_pred - y) ** 2) return loss def compute_gradient(X, y, w, b): m = len(y) y_pred = X @ w + b error = y_pred - y dw = (2 / m) * (X.T @ error) db = (2 / m) * np.sum(error) return dw, db这里X.T @ error就是矩阵形式的梯度计算,比写循环快很多。error是预测值减真实值,形状是m×1。X.T是n×m,乘起来得到n×1的梯度向量,正好对应每个特征的权重梯度。
4.3 训练循环与学习率选择
训练循环的代码很直接:
def train(X, y, lr=0.01, epochs=1000): m, n = X.shape w = np.zeros(n) b = 0.0 losses = [] for epoch in range(epochs): dw, db = compute_gradient(X, y, w, b) w -= lr * dw b -= lr * db loss = compute_loss(X, y, w, b) losses.append(loss) if epoch % 100 == 0: print(f"Epoch {epoch}, Loss: {loss:.4f}") return w, b, losses学习率的选择是个经验活。我一般从0.01开始试,如果损失下降太慢就调大,如果震荡或者发散就调小。有一个简单的判断方法:把损失曲线画出来,如果是一条平滑下降的曲线,说明学习率合适;如果上下抖动,说明太大;如果几乎不降,说明太小。
训练完之后,把学到的权重和真实权重对比一下:
w, b, losses = train(X_norm, y, lr=0.05, epochs=1000) print("学到的权重:", w) print("真实权重:", true_w) print("学到的截距:", b) print("真实截距:", true_b)正常情况下,学到的权重应该和真实权重非常接近,误差在0.1以内。如果差很多,要么是学习率不对,要么是迭代次数不够,要么是数据本身有问题。
4.4 用sklearn做对照验证
手写实现跑通之后,用sklearn跑一遍做对照,确认自己的实现没问题:
from sklearn.linear_model import LinearRegression model = LinearRegression() model.fit(X_norm, y) print("sklearn权重:", model.coef_) print("sklearn截距:", model.intercept_)如果两者结果接近,说明手写实现是对的。如果差很多,优先检查梯度公式有没有写错,特别是那个2/m的系数,很容易漏掉或者写错。
注意事项:手写实现的时候,w的初始化不要全设为0以外的奇怪值。对于线性回归,全零初始化是安全的,因为损失函数是凸的,不存在对称性问题。但如果你后面做神经网络,全零初始化会导致所有神经元学到相同的东西,那就必须用随机初始化了。
5. 实操中真正会遇到的问题
5.1 特征共线性:模型看起来能用,但权重不可信
这是多元线性回归里最隐蔽的坑。什么叫共线性?就是两个或多个特征之间存在高度相关性。比如你用"面积"和"房间数"同时预测房价,这两个特征本身就高度相关,模型就很难分清到底是哪个在起作用。
共线性的后果是什么?模型的预测值可能还行,但权重的数值会变得非常大,而且不稳定。你换一批数据重新训练,权重可能完全变样。这就导致模型没法解释,你没法说"面积每增加一平米,房价涨多少",因为这个系数本身不可信。
怎么检测?计算特征之间的相关系数矩阵,如果某两个特征的相关系数绝对值超过0.8,就要警惕了。更严谨的方法是用方差膨胀因子(VIF),VIF大于10通常认为存在严重共线性。
怎么解决?三个思路。第一,删掉其中一个特征,保留物理意义更明确或者更容易获取的那个。第二,用PCA降维,把相关特征合并成主成分,但代价是失去可解释性。第三,用正则化,岭回归(L2)对共线性有一定的缓解作用,因为它会压缩权重。
5.2 异常值:一个点毁掉整个模型
前面提过MSE对异常值敏感,这里展开说。假设你的数据里有一个样本,目标值因为录入错误多了一个零,那这个点的误差会巨大,平方之后更是天文数字。梯度下降会拼命去拟合这个点,把整个超平面往它那边拉,导致其他正常样本的预测全部变差。
检测异常值的方法很多,简单一点用箱线图的IQR规则,复杂一点用孤立森林或者DBSCAN。我一般先用可视化看一眼,散点图上明显偏离群体的点先标记出来,再判断是删除、修正还是保留。
实操心得:不要无脑删异常值。有些异常值恰恰是业务上最有价值的样本,比如欺诈检测里的异常交易。删之前先想清楚,这个异常是数据错误还是真实信号。如果是真实信号,删掉反而损失了信息。
5.3 过拟合与欠拟合:怎么判断,怎么处理
欠拟合的表现是训练集和测试集的损失都高,模型太简单,学不到数据的规律。对于线性回归,欠拟合通常意味着特征不够,或者特征和目标之间根本不是线性关系。解决办法是加特征、做特征交叉、或者换非线性模型。
过拟合的表现是训练集损失很低,测试集损失明显高出一截。线性回归本身不容易过拟合,因为它的参数量等于特征数加一,模型容量有限。但如果特征特别多、样本特别少,照样会过拟合。解决办法是加正则化,或者减少特征数量。
判断方法很直接:把数据分成训练集和测试集,训练完看两个集合上的损失差距。差距大就是过拟合,都大就是欠拟合。我习惯用70/30或者80/20的比例来分,样本少的时候用交叉验证更稳。
5.4 学习率调不好:损失震荡或者不下降
学习率的问题在梯度下降里太常见了。损失曲线如果出现明显的上下震荡,基本可以确定是学习率太大。如果损失几乎不降,那就是太小。还有一种情况是损失先降后升,这叫发散,也是学习率太大的表现。
我的调参策略是从0.1开始,每次除以3或者除以10,试几个值,看哪个损失曲线最平滑。另外,如果特征标准化做好了,学习率的容忍范围会宽很多。这也是为什么我一直强调标准化的重要性。
进阶一点的做法是用学习率衰减,比如每训练100轮学习率乘以0.9。这样前期步子大,快速接近最优解,后期步子小,精细调整。或者用Adam这类自适应优化器,它会自动调整每个参数的学习率,省去手动调的麻烦。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 损失不下降 | 学习率太小、特征未标准化 | 检查学习率和数据分布 | 调大学习率、做标准化 |
| 损失震荡 | 学习率太大 | 观察损失曲线 | 调小学习率 |
| 损失发散 | 学习率过大、数据有异常值 | 检查损失值和数据 | 调小学习率、清洗异常值 |
| 训练集好测试集差 | 过拟合 | 对比训练和测试损失 | 加正则化、减特征 |
| 权重数值异常大 | 共线性 | 计算相关系数矩阵 | 删特征、用岭回归 |
| 预测值偏移 | 截距未学习、数据未中心化 | 检查b的值 | 确保b参与训练 |
6. 正则化:让模型更稳的两个武器
6.1 岭回归:给权重加一个约束
岭回归(Ridge)在损失函数里加了一项L2正则化:
L = MSE + λ * Σwⱼ²
这个λ控制正则化的强度。λ越大,权重被压缩得越厉害,模型越简单。λ为0就退化成普通线性回归。
为什么L2正则化能缓解共线性?因为当两个特征高度相关时,普通回归会让它们的权重一个很大一个很小,相互抵消。加了L2之后,大权重会被惩罚,模型倾向于把权重分摊到两个特征上,数值更稳定。
λ的选择一般用交叉验证来定。sklearn的RidgeCV可以自动帮你选:
from sklearn.linear_model import RidgeCV alphas = [0.01, 0.1, 1.0, 10.0, 100.0] ridge = RidgeCV(alphas=alphas, cv=5) ridge.fit(X_norm, y) print("最优alpha:", ridge.alpha_) print("权重:", ridge.coef_)6.2 Lasso回归:顺便做特征选择
Lasso用的是L1正则化:
L = MSE + λ * Σ|wⱼ|
L1和L2的关键区别在于,L1倾向于把不重要的特征权重直接压到0,所以Lasso天然具有特征选择的功能。如果你的特征很多,但真正有用的没几个,Lasso能帮你自动筛掉那些没用的。
不过Lasso也有缺点。当特征之间存在共线性时,Lasso会随机保留其中一个而把其他的压到0,这个选择过程不稳定。所以如果特征都有实际意义,不想丢掉任何一个,用岭回归更合适。
from sklearn.linear_model import LassoCV lasso = LassoCV(cv=5, random_state=42) lasso.fit(X_norm, y) print("非零权重数量:", np.sum(lasso.coef_ != 0)) print("权重:", lasso.coef_)6.3 弹性网络:两者兼顾
弹性网络(Elastic Net)把L1和L2结合起来:
L = MSE + λ₁ * Σ|wⱼ| + λ₂ * Σwⱼ²
它既有Lasso的特征选择能力,又有Ridge的稳定性。当特征数量特别多、且特征之间有相关性时,弹性网络往往比单独用Lasso或Ridge效果更好。sklearn里用ElasticNetCV可以自动调参。
实操心得:正则化不是万能的。如果模型本身没有过拟合,加正则化反而会让效果变差。我一般先跑一个不加正则化的baseline,确认有过拟合迹象之后,再考虑加正则化。不要一上来就默认加,那样可能掩盖了真正的问题。
7. 模型评估:别只看MSE
7.1 常用的几个评估指标
MSE是最基础的,但它的量纲是目标值的平方,不好解释。比如你预测房价,MSE是10000,这个数字本身没有直观意义。所以实际中更常用RMSE(均方根误差),它把量纲还原回去了:
RMSE = sqrt(MSE)
还有MAE(平均绝对误差),它对异常值更鲁棒:
MAE = (1/m) * Σ|yᵢ - ŷᵢ|
以及R²(决定系数),它表示模型解释了目标值多少比例的方差:
R² = 1 - SS_res / SS_tot
R²的范围是负无穷到1。等于1表示完美预测,等于0表示模型和直接用均值预测一样差,小于0表示模型还不如直接用均值。
| 指标 | 公式 | 特点 | 适用场景 |
|---|---|---|---|
| MSE | 均方误差 | 对大误差敏感 | 需要惩罚大偏差 |
| RMSE | 均方根误差 | 量纲和目标一致 | 通用评估 |
| MAE | 平均绝对误差 | 对异常值鲁棒 | 数据有噪声 |
| R² | 决定系数 | 无量纲,可跨数据集比较 | 解释模型拟合程度 |
7.2 交叉验证:更可靠的评估方式
单次划分训练集和测试集有个问题:如果运气不好,测试集恰好比较难或者比较容易,评估结果就不准。交叉验证能缓解这个问题。K折交叉验证把数据分成K份,每次用K-1份训练,1份测试,重复K次,最后取平均。
from sklearn.model_selection import cross_val_score scores = cross_val_score(model, X_norm, y, cv=5, scoring='neg_mean_squared_error') rmse_scores = np.sqrt(-scores) print("交叉验证RMSE:", rmse_scores.mean())注意sklearn的scoring参数里MSE是负数,因为它默认分数越大越好,而MSE越小越好,所以取了个负号。这个细节第一次用的时候很容易搞混。
7.3 残差分析:看模型哪里没学好
光看一个数字不够,还要看残差。残差就是真实值减预测值。把残差画成图,能看出很多问题。
如果残差随机分布在0附近,没有明显规律,说明模型拟合得不错。如果残差呈现U型或者倒U型,说明特征和目标之间可能存在非线性关系,线性模型不够用。如果残差随着预测值增大而增大,说明存在异方差性,可能需要做目标值变换,比如取对数。
y_pred = model.predict(X_norm) residuals = y - y_pred plt.scatter(y_pred, residuals) plt.axhline(y=0, color='r', linestyle='--') plt.xlabel('预测值') plt.ylabel('残差') plt.show()这个图我每次做完回归都会看一眼,比看MSE有用得多。MSE只告诉你误差多大,残差图告诉你误差在哪里、为什么。
8. 从多元线性回归往外延伸
多元线性回归虽然简单,但它是很多更复杂模型的基础。理解它之后,往几个方向扩展都会很顺。
第一个方向是多项式回归。如果特征和目标之间不是线性关系,可以给特征加高次项,比如x²、x³,然后还是用线性回归去拟合。本质上是在更高维的空间里做线性拟合,但映射回原始空间就是曲线。sklearn的PolynomialFeatures可以自动生成这些高次项。
第二个方向是广义线性模型。线性回归假设目标值服从正态分布,如果目标值是计数数据(比如某段时间内的订单数),可能更适合泊松回归;如果目标值是二分类的(买或不买),那就是逻辑回归。这些都属于广义线性模型的范畴,核心思想还是线性组合加一个链接函数。
第三个方向是正则化和贝叶斯视角。岭回归其实等价于给权重加了高斯先验的贝叶斯线性回归,Lasso等价于拉普拉斯先验。从贝叶斯角度理解正则化,能帮你更清楚地知道什么时候该用哪种正则化。
第四个方向是特征工程。线性回归的效果很大程度上取决于特征做得好不好。特征交叉、分箱、目标编码这些技巧,在线性模型上往往比换模型更有效。我见过很多案例,把特征工程做好之后,线性回归的效果能逼近甚至超过一些复杂的非线性模型。
最后分享一个小技巧:如果你不确定该用哪个模型,先把线性回归跑通作为baseline。它的训练速度快、可解释性强、不容易出幺蛾子。如果线性回归的效果已经满足业务需求,那就没必要上更复杂的模型。很多时候,简单模型加好特征,比复杂模型加烂特征强得多。