☰
多元线性回归从原理到手写代码:正规方程与梯度下降详解
2026/10/16 15:13:42 网站建设 项目流程

随便打开一本机器学习的书,回归算法大多是第一章,而多元线性回归又是回归里最基础也最容易被低估的一块。很多初学者习惯直接调库拿系数,一旦被问到“这个系数是怎么求出来的”“正规方程和梯度下降到底是什么关系”,就答不上来了。我最近带一位零基础的A同学上手这个模型,发现真正卡住他的不是API怎么调,而是公式推导和代码实现之间的那几层窗户纸。这篇就把多元线性回归从模型形式、两种求解路径到完整的手写代码拆开讲一遍,重点解决三个问题:为什么用平方误差、最优解怎么求、写代码时哪些细节容易翻车。

如果你已经会用LinearRegression但仍然对底层原理一知半解,或者刚看完理论公式但一写代码就蒙,这篇内容会帮你在两者之间搭起一座桥。推导部分我尽量用生活化的说法,代码部分全部基于NumPy手写,不依赖任何黑盒封装,保证你理解每一行在做什么。

1. 先说思路:多元线性回归到底在求解什么

1.1 从一元到多元:模型形式发生了哪些变化

一元线性回归里,模型长这样:

y = θ0 + θ1·x

这是在拟合“一个特征x”和“一个目标y”之间的关系。但放到真实场景中,你手上的特征往往不止一个。比如预测房价,面积是一回事,地段评分、房龄、周边配套都会同时影响价格。这时候模型就要扩成:

y = θ0 + θ1·x1 + θ2·x2 + … + θn·xn

θ0是截距,θ1到θn分别代表每个特征变化一个单位时,目标值的平均变化量。这里“多元”中的“元”,指的就是特征维度。把所有特征看成多个坐标轴,模型拟合出来的不再是一条直线,而是一个高维空间里的超平面。二维平面上是一条线,三维空间里是一个平面,四维以上就只能用数学形式去理解了。

理解这个模型的关键在于:现在有多条样本,每条样本都会产生一个线性方程。假设有300条数据、每条2个特征,加上截距一共3个未知参数,但方程数有300个。未知参数3个、方程300个,这是个超定方程组,一般情况下找不到一组θ让300个方程同时精确成立。所以目标退一步:找一组θ,让模型对所有样本的预测值和真实值之间的整体偏差尽可能小。这就是“拟合”的数学含义。

1.2 矩阵化到底图什么:一次乘法代替一大堆循环

把上面的模型写成矩阵形式,会清爽得多。设:

  • X是300×3的设计矩阵,每行是一条样本,第一列全是1
  • θ是3×1的参数向量,包含θ0、θ1、θ2
  • y是300×1的真实标签

模型的预测就变成了:

h = Xθ

生活化类比:逐条样本算预测值,就像一笔一笔手工记账,不会错但很慢;矩阵乘法是批量核算,一次把所有样本的预测都算出来。更重要的是,在矩阵形式下,求导、求解、优化都能用统一的表达式操作,下面推导公式时你就能体会到这种简洁带来的好处。

很多人学到这里会问一句:设计矩阵第一列为什么非要全是1?原因很简单:截距项θ0没有和任何特征相乘,但如果我们给X补一列全1,θ0在矩阵乘法里会自动和这列1相乘,截距就被自然地纳入到统一的计算框架里了。这样做的好处是写代码时不用单独处理截距,推导公式时也不用把它拎出来单算。

2. 公式推导:正规方程与梯度下降这两条路

2.1 平方误差损失函数是怎么来的:残差服从正态分布的假设

先定义残差ε = y - Xθ。怎么衡量“整体偏差”?最自然的想法就是把所有残差平方加起来。但你有没有想过,为什么是平方,而不是直接加绝对值或者用三次方?

概率上看,如果假设ε独立同分布地服从高斯分布N(0, σ²),那么y也是高斯分布的,均值是Xθ。写成似然函数:

L(θ) = ∏ 1/(√(2π)·σ) · exp(-(yᵢ - θᵀxᵢ)² / (2σ²))

两端取对数之后:

log L(θ) = -m·log(√(2π)·σ) - (1/(2σ²))·Σ(yᵢ - θᵀxᵢ)²

第一项和θ无关,想最大化对数似然,就等价于最小化最后一项,也就是最小化平方误差和。所以最小二乘法并不是灵机一动拍脑袋选的,它和高斯噪声假设下的极大似然估计是同一件事。

平方误差在工程上还有两个好处:它处处连续可导,求梯度非常方便;它的导数是线性的,梯度表达式写出来特别简洁。相比之下,绝对值误差在0点不可导,三次方误差又会把离群点的影响放得过大,都不如平方用得顺手。最终把损失函数定义为:

J(θ) = (1/(2m))·Σ(hθ(xᵢ) - yᵢ)²

除以m是为了让损失不随样本量增大而无限膨胀,也方便和后面梯度表达式里的m保持对应;系数1/2纯粹是为了求导时消掉平方产生的2,让公式更干净,可写可不写。

2.2 正规方程推导:求导置零,一步算出最优解

损失函数写成矩阵形式:

J(θ) = (1/(2m))·(Xθ - y)ᵀ(Xθ - y)

展开之后:

J(θ) = (1/(2m))·(θᵀXᵀXθ - 2yᵀXθ + yᵀy)

对θ求梯度时,要用到两个矩阵求导事实:

  • ∂(aᵀθ)/∂θ = a
  • ∂(θᵀAθ)/∂θ = (A + Aᵀ)θ

这里的A = XᵀX是对称的,所以A + Aᵀ = 2A。经过整理:

∇J(θ) = (1/m)·(2XᵀXθ - 2Xᵀy)

令梯度等于0:

XᵀXθ = Xᵀy

如果XᵀX可逆,就得到:

θ = (XᵀX)⁻¹Xᵀy

这就是正规方程的完整推导。整个过程核心就三行矩阵代数,但背后藏着一个常见误区:实际工程里解这个方程时,通常不会真的先去算(XᵀX)⁻¹再乘,而是用数值方法直接解线性方程组。因为直接求逆会引入不必要的数值误差,在特征接近相关时会很明显。对应到NumPy,np.linalg.solve要比np.linalg.inv更稳。

复杂度上,(XᵀX)是一个(n+1)×(n+1)的矩阵,求解复杂度是O(n³)。n在几千以内完全可接受,到了几万维特征时就会明显吃力,这时候就该考虑梯度下降了。

2.3 梯度下降推导:从山坡往谷底走

正规方程是一次到位的解析解,梯度下降则是通过迭代逼近最优解的数值解法。它的思路可以类比成:站在山坡上,每次朝最陡的下坡方向迈一步,一步步走到山脚。最陡的下坡方向就是负梯度方向。

把损失函数对某个参数θⱼ求偏导:

∂J/∂θⱼ = (1/m)·Σ(hθ(xᵢ) - yᵢ)·xᵢⱼ

逐参数更新公式:

θⱼ := θⱼ - α·∂J/∂θⱼ

把整组参数合并起来,就得到向量化的更新:

θ := θ - (α/m)·Xᵀ(Xθ - y)

这里有一层很直观的含义:Xᵀ(Xθ - y)本质上算的是“每个特征和残差的加权相关性”。某个特征和残差相关性强,这个方向上的修正量就大,参数就调整得多;如果已经没什么相关性,说明这个方向上的梯度已经很小,参数也趋于稳定。

α是学习率。α太大的时候,步子迈太猛,容易在谷底附近来回震荡甚至发散;α太小的时候,慢慢挪,收敛很慢,要迭代很多轮。我实操一般从0.01起步,画损失曲线看走势再决定调大还是调小。

2.4 两个路径怎么选:规模决定方案

两条路最终都在逼近同一个最优点,选择哪一种,主要看你的数据规模。

对比项正规方程梯度下降
迭代过程不需要,一步算完需要多次迭代
主要计算量XᵀX求逆,O(n³)每轮O(m·n)
学习率不需要设置必须设置
特征缩放不是必须强烈建议
特征数量巨大不建议推荐
矩阵不可逆的情况直接报错不受影响

我个人的习惯是:几千条样本、几百个特征以内,正规方程秒出结果,完全不用折腾学习率;到了几万维特征再考虑梯度下降。深度学习框架里的线性层,本质上用的也是梯度下降思路的变体。

3. 代码实战:从零手写多元线性回归

3.1 构造模拟数据:用已知答案验证代码

写代码之前,我先造一份“答案已知”的数据。这样做的最大好处是:如果手写代码算出来的系数和真实系数对得上,就能确认代码逻辑没有错。

设定如下:两个特征,x1按均匀分布U(0,10)、x2按均匀分布U(0,20)生成;真实参数θ_true = [2, 3, 5];噪声服从N(0, 0.5²);样本量300。代码如下:

import numpy as np np.random.seed(42) m = 300 x1 = np.random.uniform(0, 10, m) x2 = np.random.uniform(0, 20, m) X_raw = np.column_stack([x1, x2]) # 原始特征,不含截距列 X_design = np.column_stack([np.ones(m), x1, x2]) # 设计矩阵,第一列全1 theta_true = np.array([2.0, 3.0, 5.0]) noise = np.random.normal(0, 0.5, m) y = X_design @ theta_true + noise

这里的y是用“真实系数+噪声”生成的标签,后面算出的θ应该接近[2, 3, 5],但不会完全相等,因为噪声把真实关系污染了一部分。这也正好用来理解回归的本质:在有噪声的数据里反推真实规律。

3.2 手写正规方程版本:三行核心代码

基于上面的X_design和y,正规方程代码可以精简成三行:

def normal_equation(X, y): theta = np.linalg.solve(X.T @ X, X.T @ y) return theta theta_ne = normal_equation(X_design, y) print(theta_ne)

代码里我用的是np.linalg.solve而不是np.linalg.inv(X.T @ X) @ (X.T @ y)。两种写法的结果几乎一样,但solve直接解线性方程组,数值上更稳定,也避免了把“求逆”这个中间步骤暴露出来。初学者先用inv理解原理完全没问题,但要意识到工程实现上有更优做法。

跑出来会看到类似[1.98, 3.02, 4.97]的效果。误差水平由噪声的标准差决定,噪声越大,估计值和真实值的偏差越大,这也是回归模型必须要看置信区间或者评估指标的原因。

3.3 手写梯度下降版本:向量化写法是重点

梯度下降的手写版本也不长:

def gradient_descent(X, y, alpha=0.01, epochs=2000): m, n = X.shape theta = np.zeros(n) losses = [] for _ in range(epochs): pred = X @ theta error = pred - y gradient = (1 / m) * X.T @ error theta = theta - alpha * gradient loss = (1 / (2 * m)) * np.sum(error ** 2) losses.append(loss) return theta, losses theta_gd, losses = gradient_descent(X_design, y) print(theta_gd) print("最后几轮损失:", losses[-5:])

最关键的一行是gradient = (1 / m) * X.T @ error。展开来看,它等价于对每个参数做一次for循环求偏导,但这里用矩阵乘法一次性完成。300个样本时循环还能忍受,一旦样本或特征规模上去,向量化和循环的性能差距是数量级的。

跑完代码之后,强烈建议把losses画出来看一眼。如果最后几轮损失还在明显下降,说明epochs不够,要继续往上加;如果曲线已经平稳住,就说明收敛得差不多了。

提示:用梯度下降跑这个例子前,最好先做特征缩放。x2的取值范围是0到20,x1是0到10,虽然差距不算大,但特征量纲不一致时梯度方向会被大范围特征带偏,损失曲线会走很多冤枉路。你先跑一遍不缩放的版本,再缩放一遍对比,感受最直观。

3.4 特征缩放:一个容易被忽略的前置步骤

最常用的标准化方法:

z = (x - mean) / std

对应代码:

x_mean = X_raw.mean(axis=0) x_std = X_raw.std(axis=0) X_scaled = (X_raw - x_mean) / x_std X_design_scaled = np.column_stack([np.ones(m), X_scaled]) theta_gd_scaled, losses_scaled = gradient_descent(X_design_scaled, y) print(theta_gd_scaled)

为什么缩放对梯度下降特别重要?因为特征取值范围不同,会让损失函数的等高线变成很扁的椭圆形。梯度方向并不直接指向椭圆中心,走出来的路径是锯齿状的,收敛又慢又颠。标准化之后,等高线会明显变得更接近圆形,梯度方向更准确,收敛快很多。正规方程不需要特征缩放,是因为它一步算出解析解,不走迭代路径。

这里要特别强调一个新手高频的坑:模型是在缩放后的特征上训练的,预测新数据时也必须用训练集的均值和标准差做同样的变换。绝不能用新数据自己的均值标准差单独算,那样特征定义就变了,模型在训练集上学的映射到新数据上就不成立。

4. 工程对照:用现成库做验证与误差评估

4.1 用scikit-learn实现:两行代码出结果

手写结果算完,最好再用成熟的库核对一遍。毕竟手写代码再仔细,也可能有某个隐蔽的拼接错误。

from sklearn.linear_model import LinearRegression lr = LinearRegression() lr.fit(X_raw, y) print("截距:", lr.intercept_) print("系数:", lr.coef_)

scikit-learn的LinearRegression默认fit_intercept=True,会在内部自动补一列全1,所以直接传入原始特征X_raw就行,不需要自己再拼设计矩阵。有一个容易混淆的点:sklearn把截距拆到了intercept_里,系数放在coef_里,这跟手写代码里的θ0和θ1、θ2统一放在一个向量中略有区别,但含义完全相同。

LinearRegression内部走的其实不是梯度下降,而是基于线性代数求解器的数值方法,等价于正规方程那条路。这也是为什么它能直接处理原始特征而不受尺度影响。

4.2 四种方式的结果对照

把真实值、手写正规方程、手写梯度下降、sklearn的结果放在一起看:

方法截距θ0θ1θ2
真实值2.003.005.00
手写正规方程约1.98约3.02约4.97
手写梯度下降(缩放后)约1.98约3.02约4.97
scikit-learn约1.98约3.02约4.97

做这个对照不是为了证明谁更厉害,而是校验手写代码有没有逻辑错误。如果某个结果偏差很大,大概率是设计矩阵拼接错了、截距列没加,或者缩放参数没有正确还原。我见过不少同学手写梯度下降和正规方程差十万八千里,最后发现是特征缩放之后忘了做逆变换。

4.3 评估指标:R²和RMSE怎么看

模型拟合得好不好,不能只靠眼睛看系数。最常用的两个指标:

  • RMSE = sqrt((1/m)·Σ(yᵢ - ŷᵢ)²),单位与y一致,反映平均误差大小
  • R² = 1 - SS_res/SS_tot = 1 - Σ(yᵢ - ŷᵢ)² / Σ(yᵢ - ȳ)²

R²的直观含义是:模型捕捉了y中多大比例的方差。R²=0.85,说明特征能解释85%的变化,剩下15%来自噪声或没纳入模型的因素。

用代码计算:

from sklearn.metrics import mean_squared_error, r2_score y_pred = lr.predict(X_raw) rmse = mean_squared_error(y, y_pred, squared=False) r2 = r2_score(y, y_pred) print("RMSE:", rmse, "R²:", r2)

在真实业务里,RMSE和y的量纲一致,方便和业务指标对齐;R²更适合描述整体解释力。要注意一点:多元回归里增加特征会让R²只升不降,因为模型总能利用多余自由度多解释一点方差。所以涉及多个候选特征时,建议参考调整R²(Adjusted R²),或者直接看测试集的表现,避免被训练集R²的虚高数值误导。

5. 常见问题与排查技巧实录

5.1 正规方程遇到不可逆矩阵,怎么办

XᵀX不可逆时,np.linalg.inv会直接抛LinAlgError。常见的触发场景有两种:特征数量大于等于样本数量,这种情况设计矩阵本身就不满秩;另一种是特征之间存在完全线性相关,比如同一个特征重复放了两遍,或者新增特征正好是旧特征的倍数。

处理方案按优先级排列:

  1. 检查特征冗余,删除重复或完全相关的特征。这是最能解释问题的做法,删完参数就有了业务含义。
  2. 改用伪逆,比如np.linalg.pinv(X) @ y。伪逆在不可逆时也能算出一个解,而且它给的是所有最优解里面范数最小的那个。
  3. 加L2正则化变成岭回归,θ = (XᵀX + λI)⁻¹Xᵀy。只要λ>0,XᵀX + λI严格正定,一定可逆。这也是从正规方程自然通向岭回归的一条重要路径。

实际业务里,我遇到最多的其实是第二种和第三种同时出现:特征相关性高、样本量又不大。直接上岭回归往往比折腾数据预处理更省事。

5.2 梯度下降不收敛,从这三个地方排查

不收敛的典型表现是损失曲线震荡、爆炸,或者下降慢到像是没动。排查顺序建议按这个来:

  1. 先看学习率。学习率过大会导致loss曲线上下剧烈震荡甚至数值溢出,从1e-2降到1e-3、1e-4逐个试,每次只改动一个变量。
  2. 再看特征缩放。未缩放时,取值范围大的特征对应的梯度分量特别大,参数更新容易在这些方向上横冲直撞。做一次标准化往往立竿见影。
  3. 最后看迭代次数。有些时候曲线看着还在缓慢下降,其实是epochs不够,把2000加到20000之后曲线自然就走平了。

排查不收敛问题时,把loss曲线打印出来是标配动作。曲线前几十轮平滑下降、尾段不再波动,基本就是收敛了;如果曲线弯弯绕绕像心电图,先降学习率,再调迭代次数,不要盲目加大epochs。

5.3 特征之间出现共线性:系数符号反常先别急着怀疑模型

多重共线性是小数据建模里最容易踩的坑。两个特征高度相关时,XᵀX接近奇异,参数估计的方差会变得非常大,系数可能出现“该正的反而是负的”这种奇怪现象。我有一次处理营销数据,线上广告费用和总营销预算几乎同步变化,单独建模时两者都和销量正相关,一旦放进同一个模型,其中一个系数直接变成负值,换一批数据又变号。

检测手段分两步:先算特征间的相关系数矩阵,绝对值超过0.7就要警觉;更严谨一点,可以计算方差膨胀因子VIF,超过10说明该特征和其他特征的信息高度重叠。处理思路也不复杂:删掉冗余特征、合并相似特征,或者直接换岭回归来稳住系数方差。

但有一点要记住:共线性不一定会让模型整体预测能力变差,它主要影响的是单个系数估计的可解释性和稳定性。如果目标只是预测精度,可以暂不处理;如果你要解读参数,必须正视这个问题。

5.4 训练集与测试集做缩放:这个坑很多人踩

拆完训练集和测试集之后,拟合scaler时必须在训练集上fit_transform,测试集上只能transform:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)

如果错误地让测试集也执行了fit_transform,相当于用测试集自己的均值和方差改变了特征的定义。模型在训练集上学到的是“缩放后特征到标签的映射”,拿到新分布的数据上就直接失效,导致测试集指标莫名其妙地偏高或偏低,整个模型评估都失去意义。

同样的逻辑也适用于手写代码:训练之前把训练集的mean和std用变量存下来,预测新样本时复用它。最省心的做法是使用Pipeline,把缩放器和回归模型放进同一套流程,fit和predict一起处理,想漏都难。

这些坑我自己都踩过一遍。印象最深的是第一次手写梯度下降时,因为少做特征缩放,盯着loss曲线看了半天没想明白为什么不降;加了一行标准化之后,几十轮就收敛了。从那以后我养成了一个习惯:拿到一个回归项目的第一件事,就是看特征有没有统一量纲、训练和测试有没有走同一套预处理流程。多元线性回归的公式本身不复杂,但把推导和代码串起来,里面其实藏着不少“不长在代码里、只长在经验里”的细节。建议你花一下午把这篇内容里的推导和代码在自己电脑上完整跑一遍,跑完之后再看sklearn那些参数,你会多理解一个维度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询