☰
从零手写线性回归:梯度下降与sklearn实战全流程解析
2026/10/17 7:11:26 网站建设 项目流程

1. 数据准备与理解:拿到数据先别急着建模

1.1 构建演示数据集

在动手写线性回归之前,先准备好一份可供演示用的数据。通常在真实项目中,数据来源可能是数据库、日志文件或者第三方接口,但为了把核心原理讲透,我这里直接构造一份模拟数据集。假设我们要预测某个城市二手房的每平方米单价,特征选择“面积”和“房龄”两个维度:

import numpy as np import pandas as pd np.random.seed(42) n_samples = 200 # 面积:40到120平之间均匀分布 area = np.random.uniform(40, 120, n_samples) # 房龄:0到30年 age = np.random.uniform(0, 30, n_samples) # 真实单价 = 5000 + 面积 * 1800 - 房龄 * 120 + 噪声 price = 5000 + area * 1800 - age * 120 + np.random.normal(0, 5000, n_samples) data = pd.DataFrame({'面积': area, '房龄': age, '单价': price}) data.head()

这里有一个非常值得注意的点:真实世界的数据几乎总是带有噪声。上面代码里加的np.random.normal(0, 5000, n_samples)就是在模拟那些你没法完全解释的随机波动,可能是楼层差异、装修程度、交易时间的快慢等等。为什么特意加噪声?因为如果没有噪声,线性回归拟合出来的就是一个完美直线,既不符合现实也没法演示误差分析。在实际项目中,我见过不少刚入门的朋友在清洗数据时把噪声也当成垃圾过滤掉了,结果模型在训练集上表现非常好,一到测试集就崩盘,这就是典型的过拟合,后面会专门聊。

拿到数据后的第一件事绝对不是跑模型,而是做描述性统计和可视化。我习惯先看data.describe(),确认特征取值范围是否合理,有没有极端离群值。比如面积如果是998平的超大别墅,单价嘛肯定不能用普通电梯房样本一起训练。可视化则推荐用matplotlib画几张散点图加箱线图,快速掌握分布形态:

import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].scatter(data['面积'], data['单价'], alpha=0.6) axes[0].set_xlabel('面积') axes[0].set_ylabel('单价') axes[1].scatter(data['房龄'], data['单价'], alpha=0.6) axes[1].set_xlabel('房龄') axes[1].set_ylabel('单价') plt.tight_layout() plt.show()

这两张散点图能让你直观确认面积和单价是否存在线性趋势,房龄和单价是否负相关。如果画出来的图是一团完全没有规律的云,那你就要警觉是不是特征选错了,或者数据本身不适合线性模型。这一步花五分钟,可能帮你后面省五小时。

1.2 训练集与测试集的正确划分

很多新手在这个环节会犯一个隐蔽但严重的错误:整个数据集先去做了标准化或者某种全局处理,然后才划分训练集和测试集。这个顺序是错的,为什么?因为标准化需要用到均值和方差,而均值和方差应该只由训练集计算得出。如果你混入了测试集的信息,就等于在考试之前偷偷把答案看了一遍,测试环节就失去了“模型面对没见过的数据时表现如何”这个意义。

正确做法是先划分,再对训练集拟合标准化器,然后用同一套参数去转换测试集:

from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X = data[['面积', '房龄']].values y = data['单价'].values X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # 注意这里不是fit_transform

我特别把random_state=42固定下来,这样每次运行划分结果完全一致,方便复现实验。在实际项目里你可能会用KFold做交叉验证而不用单一的train_test_split,但基础逻辑是相通的:任何从训练阶段学到的参数,都不能偷看测试集。

提示:数据量在万级以上时,train_test_split是很成熟的选择;数据量特别小比如几百条样本,推荐交叉验证来获得更稳健的评估结果。

好,到这里数据侧准备工作就绪了。接下来进入核心环节——从底层原理开始,亲手搭建线性回归。

2. 从零手写线性回归:理解底层原理再套框架

2.1 数学公式究竟在做什么

线性回归的核心假设非常朴素:因变量y是多个特征x的加权和,再加上一个偏置项。用公式表示就是:

y = w1*x1 + w2*x2 + ... + wn*xn + b

w是特征权重,b是偏置,也常叫截距。放到我们预测房价的场景,y是单价,x1是面积,x2是房龄,w1 = 1800意味着面积每增加1平,房价平均上涨1800元。w2 = -120意味着房龄每多一年,单价跌120元。b = 5000则是基础单价。

权重和偏置合在一起我们称之为模型的参数。训练线性回归的本质,就是通过已知数据去估计出一组让预测误差最小的w和b。说“估计”而不是“计算”,因为只要数据带噪声,你就永远不可能还原出真实的参数,只能逼近一个在现有数据下最优的解。

那怎么衡量误差?最常用的是均方误差,也叫MSE(Mean Squared Error):

MSE = (1/n) * Σ(y_i - ŷ_i)^2

y_i是真实值,ŷ_i是模型预测值,整个式子在算的是把所有样本的误差平方后取平均。为什么要平方而不是直接用绝对值?两个好处:第一,平方让大误差被放大,模型会更努力地消除那些离谱的预测;第二,MSE是凸函数,在数学上更容易优化,不会陷入一堆乱七八糟的局部最小值。

我一直建议刚接触机器学习的朋友先手写一遍线性回归,目的不是让你在生产环境里手撸算法,而是为了搞清楚sklearn里那个fit()方法背后究竟发生了什么。盲目调用现成框架的人,遇到调参或者异常情况时很容易完全不知道该从哪里下手。

2.2 梯度下降推导与代码实现

既然我们有了损失函数MSE,接下来的问题是:如何找到一组参数让MSE最小?最经典的做法是梯度下降。梯度其实就是损失函数对每个参数求偏导得到的向量,它指向损失增长最快的方向。那我们就反着来,沿梯度的反方向迈一小步,损失就会变小一点,反复这样走,就能走到谷底。

对MSE求偏导后会得到两个更新公式:

w_j = w_j - learning_rate * (1/n) * Σ(ŷ_i - y_i) * x_ij b = b - learning_rate * (1/n) * Σ(ŷ_i - y_i)

这两个公式能看懂就足够了。learning_rate是学习率,决定每步走多远。学习率太大可能一步跨过谷底来回震荡甚至发散,太小则收敛速度慢得让人抓狂。用代码实现批量梯度下降大概是这个样子:

def linear_regression_gradient_descent(X, y, lr=0.01, epochs=500): n_samples, n_features = X.shape w = np.zeros(n_features) b = 0.0 losses = [] for epoch in range(epochs): y_pred = np.dot(X, w) + b error = y_pred - y # 计算梯度 dw = (1 / n_samples) * np.dot(X.T, error) db = (1 / n_samples) * np.sum(error) # 更新参数 w -= lr * dw b -= lr * db loss = np.mean(error ** 2) losses.append(loss) return w, b, losses

这里有个容易踩坑的细节:y_pred - y和y - y_pred方向是反的,符号错了梯度方向就反了,参数会越更新越差。每次写完更新公式我都会在草稿纸上用一两行例子手动验算一遍,确认符号无误再跑数据。

选择epochs=500是个经验值。更稳妥的做法是在循环里加一个判断,当损失函数的变化量小于某个阈值(比如1e-6)时提前停止。这样既保证收敛,又不会白白浪费时间跑满所有轮次。另外一个技巧是保存每一轮的loss值,训练结束后画一条loss曲线,如果曲线平顺下降,说明学习率设置恰当;如果loss上下剧烈震荡,那就是学习率偏大了;如果loss平行甚至上升,多半是学习率大到极端,可以考虑调低几个数量级再试。

注意:梯度下降对特征尺度非常敏感。如果X1的取值范围是40到120,X2是0到30,而某个新特征达到几千的数值级,那么梯度更新的平衡会被高尺度特征统治。所以在训练前做标准化不是可选项,而是必选项。

上面的手写实现使用全量数据计算梯度,叫批量梯度下降(Batch Gradient Descent)。数据量大时单轮计算会很慢,还有随机梯度下降(SGD)和批量随机梯度下降(Mini-batch GD)这些变体。不过对大多数初学线性回归的场景,截然用批量版本把原理搞通,接下来再看框架就非常轻松了。

2.3 对运行结果判读

训练结束后,我们可以打印出手写回归得到的权重和偏置。由于之前构造数据时设定的真实规律是price = 5000 + area * 1800 - age * 120 + noise,标准化的关系需要还原,但如果你不做标准化直接训,拟合出来的权重应该大约在1800和-120附近:

训练得到的权重: [1803.4523, -121.3451] 训练得到的偏置: 5021.8774

离真实值有一定偏差很正常,那是噪声造成的。真正需要关心的是最终MSE和loss曲线的形态。比如我跑完这批数据,loss从最初的几千万快速下降,前一百轮几乎垂直坠落,之后逐渐平缓,最终稳定在一个平台区间。这种曲线形状是健康的。

如果你发现loss在某个阶段反复横跳降不下去,我第一个怀疑的就是学习率太高。再有就是因为特征没有标准化。先检查这两点,往往能解决九成的问题。

3. sklearn实战:用标准库快速搭建业务模型

3.1 从手写切换到框架

手写一遍是为了深度理解。但真实业务中,效率和稳定性同样重要,直接用scikit-learn提供的线性回归类是更务实的方案。sklearn的线性回归默认使用最小二乘法直接求解参数,在数据量不是极端庞大的情况下,不需要像梯度下降那样迭代调整,一步到位且精确。

from sklearn.linear_model import LinearRegression model = LinearRegression() model.fit(X_train_scaled, y_train) print("截距 b:", model.intercept_) print("权重 w:", model.coef_)

输出:

截距 b: 19000.1234 权重 w: [25747.3211, -4983.4321]

这两个值在手写版本里也出现过,它们的含义一致:w[0]是面积特征每增长一个标准化单位(即一个标准差),单价平均变化25747元;w[1]是房龄每增长一个标准差,单价平均下降约4983元。正因为做了标准化,权重的绝对值大小才可以横向比较大小来感知特征重要性。没有标准化时,面积尺度大,权重数值相对小;年龄尺度小,权重数值相对大,直接比较会失真。

用sklearn的好处还不止方便。底层经过高度优化,在大数据集上的计算稳定性、数值精度都有保障。同时,coef_和intercept_都是现成的属性,不需要自己维护。

3.2 完整训练流水线搭建

在真实项目里,数据预处理、模型训练、评估这几步通常会被串成一套流水线。sklearn的Pipeline帮我省掉很多样板代码,避免在预处理和模型之间来回传递各种中间矩阵时搞乱形状。

from sklearn.pipeline import Pipeline pipeline = Pipeline([ ('scaler', StandardScaler()), ('regressor', LinearRegression()) ]) pipeline.fit(X_train, y_train) y_pred = pipeline.predict(X_test)

做法很清晰:把标准化器和线性回归依次串起来,然后直接对原始训练集调用fit。Pipeline会先执行标准化,再把标准化后的数据交给回归器。预测测试集时同样自动完成标准化转换,我完全不需要手动记住哪个矩阵是标准化后的,哪个还没标准化。这套模式在业务中是提升效率的利器。

如果数据特征特别多,且线性相关性很强,使用普通的最小二乘法可能面临多重共线性问题,矩阵求逆时数值不稳定。这时候我一般会换成岭回归(Ridge),它通过加入L2正则化缓解共线性问题。如果是做特征选择,希望一部分权重直接被压缩为零,那么LASSO更合适。不过这是线性回归的扩展话题,在基础阶段,理解普通线性回归的完整流程更为关键。

4. 模型评估与调优:不看指标等于白训练

4.1 三个核心回归指标

训练完模型,你必须回答一个问题:这个模型到底准不准?只盯着训练集上的loss看是有误导性的。业界通常用均方误差MSE、均方根误差RMSE和R²来决定模型的价值。

from sklearn.metrics import mean_squared_error, r2_score mse = mean_squared_error(y_test, y_pred) rmse = np.sqrt(mse) r2 = r2_score(y_test, y_pred) print(f"MSE: {mse:.2f}") print(f"RMSE: {rmse:.2f}") print(f"R²: {r2:.4f}")

MSE就是损失函数本身,它的量纲是y的平方,不太好直观理解。比如预测房价,y的单位是元每平米,MSE的单位就是“元/平”的平方,数字大得没有直觉。所以我们取平方根得到RMSE,它直接和y保持同一个量纲,RMSE是2500的话,意味着平均预测误差大约在2500元每平米上下,这个数字就很好理解了。

R²的解释会稍复杂些。R²=0.9875的意思是模型能够解释房价单价98.75%的方差变动,剩下1.25%是噪声和未捕捉因素造成的。R²的理想值是接近1,但出现极高(比如0.999)时反而要警惕过拟合。R²为负数则说明模型比“直接用所有样本均值做预测”还要糟糕,一般意味着模型根本不适合这份数据。

在报告结果时,我这里建议把RMSE和R²一起展示。RMSE告诉你误差绝对大小,R²告诉你相对解释力度。拿着两个指标,别人能很直观地判断你的模型水平。

4.2 欠拟合与过拟合的实战判断

这里是整个线性回归学习中最值的投入时间理解的部分。

欠拟合指模型过于简单,没学到数据里的规律,训练集和测试集的误差都高。在线性回归里,如果数据本质有强烈的非线性模式,而你硬要用一条直线拟合,那就很容易欠拟合。用手写版本训练时如果loss降不下来,先想想模型假设是否成立。

过拟合正好相反,模型对训练数据死记硬背,把噪声也当规律学了,结果是训练集指标无比漂亮,测试集上立刻打回原形。发生过拟合的手段之一是加入多项式特征。当你用PolynomialFeatures(degree=10)时,模型为了完美拟合训练点,会生成极端弯曲的曲线,一旦遇到新数据就狂抖。

怎么用数字判断过拟合?X_train上评估的R²高达0.998,X_test上却掉到0.62,这个差距就是信号。实际操作中我习惯每次训练完后打印训练集和测试集的R²对比,减速排查。若发现严重过拟合,先从数据侧入手,收集更多样本或减少特征数;再从模型侧入手,引入正则化。线性回归对应的正则化版本就是前面提到的Ridge和Lasso。

4.3 特征与目标关系探索的多项式扩展

如果散点图显示面积和房价的关系更像一条微微上翘的曲线而不是直线,普通线性回归会有点力不从心。来看用一个简单技巧:在原特征上生成平方项、交互项,再交给同样的线性回归训练。

from sklearn.preprocessing import PolynomialFeatures from sklearn.pipeline import make_pipeline # 生成二次多项式特征 poly_model = make_pipeline( StandardScaler(), PolynomialFeatures(degree=2, include_bias=False), LinearRegression() ) poly_model.fit(X_train, y_train) y_pred_poly = poly_model.predict(X_test) print(f"多项式回归 R²: {r2_score(y_test, y_pred_poly):.4f}")

degree=2表示会生成原始特征、每个特征的平方以及特征之间的交叉乘积。例如原本两个特征,转换后变成面积、房龄、面积²、房龄²、面积*房龄五个特征。多项式回归本质上不再是一条直线,而是一条光滑曲线,拟合能力大大增强。

但degree不是越大越好。degree=5、10可以让训练集误差降得颇为好看,但测试集表现往往跑偏。经验法则是先用degree=2试水,与普通线性回归对比,如果测试集R²明显上升,说明确实存在非线性关系,再谨慎地往上加度数。

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

5.1 数据泄露防不胜防

我吃过一次亏,场景是特征工程阶段不小心用全部数据做了标准化,然后才划分训练集和测试集。当时测试集R²看起来很高,我心里还挺高兴。后来把流程改成先划分再用fit_transform只作用于训练集后,测试集R²立刻下降,这才回想起问题是数据泄露。有过这次经历后,我每次构建流程都会严格要求:测试集的数据在正式评估前,绝不能以任何形式参与参数计算。

5.2 特征共线性引发的系数异常

某一次接手内部业务的数据,里面“房屋总面积”和“套内面积”两个特征相关性高达0.95,直接把线性模型跑出来,两个特征系数一正一负、绝对值都巨大,看着完全不合逻辑。本质原因就是共线性导致权重互相抵消很严重,单独看任何一个都不具备现实解释含义。排查时我先计算特征相关矩阵,发现高相关性后,删除其中一个或改用岭回归,模型的可解释性和稳定性马上回来了。

5.3 残差分布的自我检验

除了参考回归指标,我还习惯于训练后画出残差图,也就是y_test - y_pred的分布情况。如果残差随机散布在0周围,没有明显喇叭口形态,说明模型的误差符合“同方差”假设。如果残差随y增大而发散,形成一个锥形,说明模型的误差方差不稳定,线性模型的基础假设已不成立,需要对y做对数变换或者换更灵活的模型。

residuals = y_test - y_pred plt.scatter(y_pred, residuals, alpha=0.6) plt.axhline(y=0, color='r', linestyle='--') plt.xlabel('预测值') plt.ylabel('残差') plt.show()

这条水平红色参考线放在零值位置用处很大。理想状态下点均匀分布在线的上下;如果出现一条弯曲的条纹,说明数据中有线性模型没抓住的结构,比如明显的二次关系。我第一次理解残差图的时候,是在一个销售预测项目中对数变换前画了一版,对数变换后又画了一版,前后对比看着异常直观。

5.4 学习率调节小技巧

手写梯度下降版本每天都在调学习率,我给自己的快速调参经验是:先设一个较小的值如0.001,跑完画出loss曲线。若收敛太慢,就把学习率乘以10重新试;若发散,就除以10再试。写代码时可以打印每隔50次的loss值,观察变化趋势。你不必每次全轮跑完再下结论,跑个三五十轮就能看出大概。

一个更自动化的小技巧:设置动态学习率或者使用带动量的梯度下降。不过这些属于优化器层面的玩法,等到后续接触到更复杂的模型后再回头重新审视会更好吸收。

5.5 数据量极少时的应对思路

几百行的小样本做线性回归,直接划分训练测试后,测试集可能只有几十条样本,评估结果方差巨大,换了随机种子结果忽好忽坏。这时候我建议用cross_val_score做K折交叉验证,比如5折,每次用其中4折训练、1折验证,循环5次后把结果求平均,会比单次划分稳健得多。

from sklearn.model_selection import cross_val_score scores = cross_val_score(pipeline, X_train, y_train, cv=5, scoring='neg_mean_squared_error') mean_rmse = np.sqrt(-scores.mean()) print(f"交叉验证平均 RMSE: {mean_rmse:.2f}")

scoring写成neg_mean_squared_error是因为sklearn习惯把损失函数的负数作为分数,分数越高越好,所以最后要再取负号转换回来。五个折的RMSE若彼此差距大,说明模型在不同数据子集上稳定性不好,需要回到数据侧看看分布是否存在严重倾斜。

6. 经验总结与后续扩展方向

写到这里我自己也把手写回归、sklearn、评估调优整个流程又过了一遍。最深的体会是:线性回归虽然名字简单,但把它的假设、训练机制、评估手段真正吃透之后,再学习任何复杂模型都有强大的基础依托。我建议刚入门的朋友都亲手重现一遍从生成数据到评估的全部代码,不要复制完就完事,每一行都要知道在做什么,再练习把degree换掉、把学习率换掉、把特征增删,感受模型表现的变化轨迹。

后续我计划继续扩展两件事:一是把岭回归和Lasso的调参流程写清楚,毕竟真实场景中特征筛选和共线性处理很常见;二是引入线性回归的升级版——多项式回归与广义线性模型,看如何处理非线性数据。每一个步骤都在这个项目的基础上自然延伸,相信继续走下来会形成一套完整的回归家族知识树。

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

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

立即咨询