前两天有个朋友把“linear代码线性回归”这个搜索词丢给我,说想学机器学习线性回归,结果搜出来的东西杂得离谱——有讲linear decoders的,有讲hex start linear address record这类十六进制记录格式的,还有直接甩三行sklearn代码让你跑的。我太理解这种感受了:理论公式看不懂,代码跑通了又不知道它到底在算什么。这篇我打算按自己带项目时的思路走一遍:先用手写numpy把线性回归算法跑通,再切到sklearn和statsmodels做正规建模,然后补上特征工程和正则化,最后聊聊模型上线后最容易踩的四个坑。适合已经会写Python但没系统做过机器学习线性回归实验的人,也适合那些只会调用LinearRegression.fit却解释不了系数含义的人。
1. 线性回归到底在拟合什么:从直觉到矩阵表达
1.1 一条直线能解释多少业务问题
线性回归的直觉非常朴素:找一条直线,让所有样本点到它的垂直距离总和尽量小。比如预估房租,把面积作为特征x,房租作为目标y,我们假设每平方米单价基本稳定,那模型就是 y = w * x + b。这里的w就是面积每增加一平米租金平均涨多少,b可以理解为固定费用的底价。
这个例子听起来过于简单,但很多复杂业务的起点都长这样。我之前做过一次门店销量预测,第一版模型就是用“客流量 + 平均客单价 + 当天是否为周末”这三个特征做线性回归,效果已经能超过店长的拍脑袋估算。业务方要的不是炫技,而是一个能说清楚解释逻辑的模型。线性回归的优势就在这里:系数可以直接反推业务含义。
需要注意,“线性”不代表数据必须真的在一条直线上,而是说特征和目标之间的关系可以用加权求和表达。真实世界里完全线性的关系很少,但在局部范围内,线性近似往往够用。这也是为什么像语音识别、推荐系统里的深度学习模型,第一层很多也是线性变换——神经网络本质上就是一堆线性回归结构叠加非线性激活函数。
1.2 误差最小化的数学选择:为什么偏偏是MSE
既然是找直线,就要定义什么叫“拟合得好”。最常用的做法是最小化均方误差(MSE),也就是把所有样本的误差平方加起来再取平均。公式写成:
loss = (1 / n) * Σ (w * x_i + b - y_i)²
为什么用平方而不是直接用绝对误差?原因有三个。第一,平方对大误差的惩罚更重,模型会被迫更关注那些偏离很远的点;第二,平方项是光滑可导的,后面做梯度下降非常方便;第三,在误差满足独立同分布的正态分布假设下,最小化这个平方误差等价于最大似然估计,有统计学上的理论依托。
有些人会问,那用MAE(平均绝对误差)不行吗?也可以用,MAE对异常值的敏感度更低,但它在零点不可导,优化起来要绕弯子。实际业务里,如果异常值本身是噪声,用MAE会更稳;如果异常值也是真实业务信号,用MSE反而能逼着模型去解释它。这个选择没有标准答案,但默认从MSE入门一定不会错。
把loss分别对w和b求偏导,可以得到两种参数更新方式。一种是梯度下降,每轮迭代都沿着梯度的反方向调整参数;另一种是令偏导等于0,直接解出参数。下一章我会把这两种方式都写一遍代码,直观感受它们的差别。
1.3 一口气看懂矩阵形式 w = (X^T X)^{-1} X^T y
如果把所有样本堆叠成矩阵X,每行是一个样本,每列是一个特征,线性回归的损失函数就能写成向量形式。对向量形式的loss求导并令其等于0,就能得到传说中的正规方程:
w = (X^T X)^{-1} X^T y
不理解这个公式的人会死记硬背,理解的人会从几何角度把它看穿。X的每一列是一个特征向量,这些特征向量张成一个空间,Xw就是对这个空间里的所有点做线性组合。我们要找的组合,是让Xw这个预测向量离y这个目标向量欧氏距离最近,也就是把y投影到特征张成的空间上。投影点就是Xw,投影系数就是w。正规方程的本质是一次正交投影。
这个公式能成立有个前提:X^T X必须可逆。翻译成大白话就是,特征之间不能存在完全共线性(一个特征是另一个特征的线性组合),样本个数要大于特征个数。如果X^T X接近奇异,直接求逆数值上会非常不稳定,这种时候用np.linalg.lstsq会比手动算逆矩阵稳妥得多。后面踩坑章节里会具体讲,多重共线性是怎么让系数符号变得反直觉的。
2. 手写一个线性回归:numpy版的完整训练循环
2.1 造一份带噪声的数据,把最小流程跑通
学回归最忌讳直接拿真实数据开干,因为你不知道正确的参数长什么样,出了问题也很难判断是自己写错了还是数据本身有噪声。我建议先造一份完全可控的数据,把整个训练周期跑通,再换真实数据。
import numpy as np rng = np.random.default_rng(42) X = np.linspace(0, 10, 100).reshape(-1, 1) # 100个样本,1个特征 true_w, true_b, noise_scale = 3.0, 2.0, 1.5 y = true_w * X.ravel() + true_b + rng.normal(0, noise_scale, size=100)这里的X从0均匀取到10,真实斜率是3,截距是2,再加上标准差为1.5的高斯噪声。先用散点图看一眼数据分布,再开始建模。注意我用了rng = np.random.default_rng(42)而不是老式的np.random.seed,这是numpy现在推荐的做法,每次生成的数据可复现,也避免全局随机种子污染其他模块。
2.2 梯度下降核心循环:参数更新与收敛观察
下面这段是我最喜欢用来给新人演示的完整训练循环,全部代码只有十几行:
n = len(X) w, b = 0.0, 0.0 lr = 0.01 epochs = 1000 losses = [] for epoch in range(epochs): y_pred = w * X.ravel() + b loss = np.mean((y_pred - y) ** 2) losses.append(loss) grad_w = 2 / n * np.sum(X.ravel() * (y_pred - y)) grad_b = 2 / n * np.sum(y_pred - y) w -= lr * grad_w b -= lr * grad_b if epoch % 100 == 0: print(f"epoch {epoch}, loss = {loss:.4f}, w = {w:.3f}, b = {b:.3f}")跑完之后w会收敛到3.0附近,b收敛到2.0附近,loss从初始的几十一路降到2左右。注意loss最后不会等于0,因为有噪声存在,这是正常现象,不要为了追求loss为0而做多余的事。
学习率lr是整个过程中最敏感的旋钮。lr设得太小,比如0.0001,跑1000轮还没走到最低点;lr设得太大,比如0.5,loss会在某轮突然变成几千,参数直接发散。我自己的经验是,先决定用批量梯度下降、随机梯度下降还是小批量。上面的代码每次更新用全量数据算梯度,是小数据集最稳的做法;数据量大了之后,每轮迭代只随机抽一批样本计算梯度,能大幅加快收敛速度,但梯度的噪声会大一些。实际项目中,线性回归很少会真的手写训练循环,但这个循环给你一种“参数到底怎么被更新”的手感,后续调任何模型都有帮助。
2.3 闭式解与梯度下降怎么选
正规方程可以一行代码求解:
X_design = np.hstack([X, np.ones((len(X), 1))]) w_opt, residuals, rank, s = np.linalg.lstsq(X_design, y, rcond=None) print(w_opt) # 输出类似 [3.02, 1.91]:斜率接近3,截距接近2np.linalg.lstsq实际上是用SVDSVD分解来求解最小二乘问题,比直接算np.linalg.inv(X.T @ X) @ X.T @ y数值上更稳定,即使X^T X接近奇异,也能给你一个合理的结果。我强烈建议以后凡是要求线性回归系数,优先用lstsq而不是手写求逆。
两者怎么选,我在实际工作中按这个原则来:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 样本量小、特征数少(比如几千行、几十个特征) | 闭式解 | 一次性精确求解,无收敛问题 |
| 样本量百万级、特征维度高 | 梯度下降/小批量梯度下降 | 闭式解要构造X^T X,内存和时间都扛不住 |
| X^T X接近奇异 | 闭式解用lstsq | SVD数值稳定性更好 |
| 后续要加L1/L2正则化 | 梯度下降或专用求解器 | 带约束的最优化问题不适合直接求逆 |
还有一个坑:闭式解的复杂度大约O(n*d²),当特征维度d上万时,光算一次矩阵乘法就可能内存爆炸,更不要说求逆了。所以“特征多”和“样本多”这两个词在实际建模中会把你推向完全不同的技术路线。
2.4 评估模型:R²、RMSE、MAE别搞混
训练完成之后,第一个感觉是loss变低了,但这还不够,需要一套能骗过自己的评估指标。我常用的三个是:
from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error # 假装已经用训练好的w和b做了预测 y_pred = w * X.ravel() + b r2 = r2_score(y, y_pred) rmse = np.sqrt(mean_squared_error(y, y_pred)) mae = mean_absolute_error(y, y_pred)R² 的含义是模型解释了目标变量多少比例的方差,取值最大为1,但可以变成负的。如果R²是0.94,可以粗粗理解为“94%的波动能被特征解释掉”。RMSE和MAE都是误差量纲,RMSE因为平方的存在,对大误差更敏感,所以RMSE一般会大于MAE,如果两者差距很大,说明数据里有少数样本误差特别大,要小心异常值。
最关键的评估纪律是:必须在测试集上算指标,不能用训练集。很多人训练完顺手在训练集上打印R²,看到0.98就兴奋,上线之后被打回原形。我习惯是先train_test_split划分数据,train上训练,test上评估,并且把两个数据集上的R²一起打印出来。如果train的R²是0.98,test是0.72,那大概率是过拟合或者数据分布不一致,而不是模型不行。
3. 从手写到库调用:sklearn 与 statsmodels 的分工
3.1 sklearn的LinearRegression:fit之后你该拿什么
手写完一遍之后再看sklearn,你会觉得整个世界都清爽了,但也会发现一些容易误用的细节。最基本的用法:
from sklearn.linear_model import LinearRegression 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, random_state=42) model = LinearRegression() model.fit(X_train, y_train) print(model.coef_) print(model.intercept_)fit完成之后,常用的就是coef_和intercept_,分别对应w和b。注意sklearn的LinearRegression默认是普通最小二乘OLS,内部用的就是类似lstsq的解析解法,不是梯度下降,所以它对特征是否归一化不敏感,这也是它为什么经常被人直接拿来fit的原因。
但有几个限制它不会主动告诉你。第一,它不返回特征的p值,你无法直接知道哪个特征在统计上不显著;第二,它不做特征选择,100个特征它会全给你系数;第三,它遇到缺失值直接报错,需要你提前处理NaN。很多场景下,线性回归的目标不只是预测,还要解释特征效果,这时只靠sklearn是不够的,需要请出statsmodels。
3.2 statsmodels:给回归补上统计推断这一课
如果你要给业务方汇报“广告投放对转化的影响是否显著”,最合适的工具不是sklearn,而是statsmodels:
import statsmodels.api as sm X_with_const = sm.add_constant(X_train) # 必须自己加截距列 ols_model = sm.OLS(y_train, X_with_const).fit() print(ols_model.summary())输出会包含一张大表,里面有coef、std err、t值、P>|t|、置信区间,以及顶部的R-squared和F-statistic。对业务评审来说,P>|t|是最值得看的:它告诉你这个系数是否显著区别于0。如果某个特征的p值大于0.05,不管系数绝对值多大,都不能拍着胸脯说它对目标有稳定的影响。
还有一个细节:sklearn的LinearRegression会自动帮你拟合截距,但statsmodels的OLS不会。忘记加sm.add_constant的话,等于强迫回归线穿过原点,拟合结果会和sklearn完全不同。这是我见过新手最常犯的错误,没有之一。
什么时候用sklearn,什么时候用statsmodels,我的分工是:预测任务优先sklearn,因为它与Pipeline、cross_val_score这些工具配合流畅;需要出回归报告、看显著性、算置信区间,就切到statsmodels。两者训出来的系数在相同数据下几乎一致,但输出信息量差很多。
3.3 回归图表的正确打开方式
建模报告里最少不了画像。最常见的画法是散点图加一条回归直线:
import matplotlib.pyplot as plt plt.scatter(X_test, y_test, alpha=0.6) plt.plot(X_test, model.predict(X_test), color="red", linewidth=2) plt.xlabel("feature") plt.ylabel("target")但这条线很容易掩盖问题。真正该看的图是残差图,横轴是预测值或某个特征,纵轴是残差(真实值减预测值)。如果残差随机分布在0附近,说明模型拟合合理;如果残差呈喇叭形,说明方差不是常数,可能需要对目标做对数变换;如果残差有明显曲线趋势,说明漏掉了非线性特征。
residuals = y_test - model.predict(X_test) plt.scatter(model.predict(X_test), residuals) plt.axhline(y=0, color="gray", linestyle="--")我在项目里几乎不看那条回归线,只看残差图。一张好看的残差图能省下很多试错时间。
4. 特征工程与正则化:线性回归从能用变好用
4.1 特征缩放:梯度下降的隐形关卡
如果你一直用sklearn默认参数做线性回归,可能从来没感知过特征缩放的重要性,因为在普通最小二乘里它确实无所谓。但一旦你开始用Ridge、Lasso,或者自己写梯度下降,特征缩放就直接决定模型能不能收敛。
原因很简单:不同特征的数值尺度差得远时,损失函数的等高线会变成细长的椭圆,梯度方向一会儿偏向这个特征,一会儿偏向那个特征,形成Z字形震荡,收敛速度慢得像蜗牛。把特征都缩放到均值0、标准差1之后,等高线更接近圆形,梯度方向能直接指向最低点。
from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline pipe = make_pipeline(StandardScaler(), LinearRegression()) pipe.fit(X_train, y_train)注意两个容易踩的细节。第一,缩放器只能用训练集拟合,测试集上只调用transform,不能把train和test拼在一起fit,否则测试集的信息会通过scaler的均值和方差泄漏到训练流程里。第二,如果是做可解释性报告,缩放过后的系数就不再是“x每增加一个单位y增加多少”,而是“x每增加一个标准差y增加多少”,给业务方解释前一定要先想清楚。
4.2 PolynomialFeatures:让直线学会弯曲
线性回归只能拟合直线,但真实关系常常是曲线。解决办法不是换模型,而是把原始特征扩展成多项式特征,再让线性模型去拟合这些新特征。比如只有一个特征x,扩展2次方之后得到[x, x²],线性回归就变成二次曲线拟合;扩展到3次方,可以拟合更复杂的弯曲形状。
from sklearn.preprocessing import PolynomialFeatures poly = PolynomialFeatures(degree=2, include_bias=False) X_poly = poly.fit_transform(X_train)这招简单有效,但很容易过头。degree=5的时候,训练集R²可以无限逼近1,测试集R²反而崩掉,因为模型开始死记硬背训练集中的每个点,曲线的抖动越来越大。我在实验里见过不少二次方、三次方效果很好,一加到五、六次方就完全失控的案例。
控制办法是交叉验证。把PolynomialFeatures和线性回归放进Pipeline,再用GridSearchCV去搜索degree,比手动尝试靠谱得多:
from sklearn.model_selection import GridSearchCV param_grid = {"polynomialfeatures__degree": [1, 2, 3, 4]} grid = GridSearchCV(make_pipeline(PolynomialFeatures(), LinearRegression()), param_grid, cv=5) grid.fit(X_train, y_train) print(grid.best_params_)4.3 L1和L2正则化的选择逻辑
当特征数量变多,线性回归的系数会变得很不稳定,甚至出现数值巨大的正负抵消。正则化就是在损失函数后面加一个惩罚项,用一点点偏差换取方差的大幅下降。
Ridge(L2)惩罚系数平方,效果是让所有系数都往0缩但不会等于0;Lasso(L1)惩罚系数绝对值,会让一部分系数被压成真正的0,相当于自带特征选择。实际选择时我这么判断:如果只需要防过拟合、提升稳定性,用Ridge;如果需要从几百个特征里筛出少量关键特征,用Lasso;如果既需要分组特征挑选又想要稳定,可以试试ElasticNet。
from sklearn.linear_model import Ridge, Lasso ridge = Ridge(alpha=1.0) lasso = Lasso(alpha=0.01)alpha是正则化强度,它越大,惩罚越重,系数越趋近0。alpha的选择不要靠拍脑袋,优先用RidgeCV、LassoCV或者GridSearchCV做交叉验证。一个常见误解是“加了正则化就一定更好”,不一定;在特征少且相对独立的数据上,普通最小二乘往往就是最好的,正则化反而会让模型变钝。
5. 上线前最值得排查的四个问题
5.1 多重共线性:系数符号反直觉
先讲一个真实案例。某次广告效果分析里,我把“广告费用”和“曝光量”同时作为特征放进线性回归,结果广告费用的系数居然是负的,意思是花钱越多销售越少。业务方看到这个结果直接炸了,但单独跑广告费用一个特征时,系数明明是正的。
原因是这两个特征高度相关:广告费用高,曝光量就大,它们几乎在表达同一件事。模型在分配解释权时,把大部分效果归给了曝光量,广告费用的系数就被挤成了负值。这不是数据造假,而是多重共线性造成的系数不稳定。检测方法很简单,算一下方差膨胀因子VIF,一般超过10就要警惕。处理办法有几种:删掉相关性高的特征之一、用PCA压缩成主成分、或者改用Ridge让系数更稳定。最要紧的是,不要看到系数符号就直接下业务结论,先检查特征之间的相关性矩阵。
5.2 一个异常值能把斜率拉歪多少
MSE对异常值异常敏感。我做过一次小实验,100个正常样本拟合出斜率3.0,往里放一个x=100、y=5的极端点,斜率直接掉到1.8。那个点本身离正常模式很远,但因为平方误差的存在,模型宁可牺牲大量正常样本的拟合精度,也要把直线往那个点那边拽。
排查方法:训练完之后先看残差图,标出标准化残差绝对值大于3的样本;再用IQR或Z-score检测异常值。处理方式要分情况:如果异常值明显是录入错误或传感器故障,删掉或修正;如果是真实业务极端事件,比如双十一当天的销量,贸然删除反而会把模型训练偏。更稳的做法是用HuberRegressor,它对异常值的敏感度比普通最小二乘低得多,不需要手工删点。
5.3 R²很高但预测很差:先查数据泄露
有一种情况特别迷惑人:训练集R²非常高,测试集指标也还不错,但模型上线后预测结果一塌糊涂。这种事我排查过好几次,根因几乎都是数据泄露,也就是模型在训练时偷看到了不该看到的信息。
最常见的三种泄漏源:第一,特征里包含了目标变量的未来信息,比如用“当天是否已售完”来预测“当天销量”,这是拿结果预测结果;第二,特征缩放时把全量数据的均值方差一起算,train和test的信息混在一起;第三,时序数据没有按时间切分,而是随机shuffle,导致模型提前“见过”未来。
排查链路我自己是固定的:先检查特征构造代码里有没有直接或间接引用目标;然后确认train/test划分边界,时间序列一律用时间切分;最后在测试集上画“预测值vs真实值”散点图,如果分布明显偏离,再回去看数据管道。R²高从来不是目标,稳定性才是。
5.4 上线后的漂移监控与重训触发
模型不是上线就完事。运营策略调整、用户习惯变化、外部环境改变,都会导致训练时学到的规律慢慢失效。我的做法是上线时就预留两个监控指标:一是模型预测均值,二是特征分布。用PSI(群体稳定性指标)对比近30天特征分布与训练集分布的差异,PSI如果超过0.25,基本可以判定特征漂移严重。
监控指标下降时不要急着盲目重训。先把新旧模型的预测结果在同一个测试集上跑一遍,确认新模型是确实更好,还是只是拟合了最近的短期波动。很多团队踩过这个坑:一看到线上指标下滑,立刻用最近一个月数据重训,结果新模型更差,因为最近一个月正好赶上活动期,数据分布是扭曲的。稳妥的做法是设定重训触发条件,比如“连续7天RMSE超过基线的1.2倍”,而不是遇到波动就手动操作。
这个问题的处理没有银弹,但有一点可以确定:你手里那版最朴素的线性回归baseline,是所有后续复杂模型对比的锚点。如果连baseline的指标都没有记录,后面就谈不上监控和优化。
我个人这几年的体会是,线性回归从来不是一个“只能用来练手”的玩具算法。它是一切线性模型的骨架,也是理解岭回归、Lasso、逻辑回归甚至神经网络第一层的起点。真正考验人的不是那几行fit代码,而是你能不能解释每个系数、能不能在数据怪异时稳住阵脚、能不能在指标异常时快速定位原因。如果你现在正准备做第一个回归项目,我的建议是先花十分钟手写一遍numpy版的梯度下降,再换到sklearn,然后认认真真存一份baseline的R²和RMSE。这个习惯会在之后的每一次建模中帮你省下大量的返工时间。