1. 为什么把第五天留给回归:先搞清楚它在解决什么问题
如果你正在跟着一份计划学习机器学习,前四天大概率已经接触了数据清洗、特征工程和分类模型的基本套路。到了第五天专门讲回归代码,很多人第一反应是"回归不就是预测一个数吗?有什么好细看的"。这个想法我理解,但实际写起来你会发现,回归这一支看起来简单,背后牵扯的东西一点都不比分类少。
回归分析的本质,是建立一个从特征到连续数值目标的映射关系。我们天天听到的房价预测、销量预估、温度预报、股票价格走势,都是典型回归场景。它和分类最大的区别就是:分类输出的是离散的类别标签,比如"垃圾邮件/正常邮件";回归输出的是连续值,比如"明天这件商品的销量是327件"。
在动手写代码之前,建议你先想清楚三件事:
- 你的目标变量是不是连续数值?
- 你手头的数据量大概是什么量级,几十条还是几十万条?
- 你更看重预测精度还是模型可解释性?
这三个问题的答案,直接决定你接下来选哪个模型、写什么样的代码。我见过太多人一上来就调XGBoost,连数据长什么样都没看,结果在小样本数据集上过拟合得一塌糊涂。回归代码的第一步不是import库,而是想清楚模型选型的大方向。
这篇内容就是围绕"回归代码"这条主线,从最基础的线性回归一步步写到树模型和梯度提升,每一段代码我都会讲清楚为什么要这样写、每一步在干什么、跑完以后怎么看结果。如果你正在走一条类似的系统学习路径,这天安排得很值。
2. 回归代码前的统一准备:数据、评估指标和代码骨架
2.1 先定评估指标:MAE、MSE还是R²
回归模型的评估和分类完全不是一个套路。分类看准确率、精确率、召回率,回归看的是预测值和真实值之间的差距。常用指标就三个:
| 指标 | 全称 | 含义 | 适用场景 |
|---|---|---|---|
| MAE | Mean Absolute Error | 绝对误差的平均值 | 对异常值不敏感,解释直观 |
| MSE | Mean Squared Error | 平方误差的平均值 | 放大较大误差,对异常值敏感 |
| R² | R-Squared | 模型解释方差的比例 | 衡量模型整体拟合优度 |
我自己的习惯是:先看R²判断模型整体效果,再用MAE判断误差的实际量级。如果你只盯着MSE,一个离群点就可能让误差看起来大得离谱,这会误导你的调参方向。
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score y_true = [3.0, -0.5, 2.0, 7.0] y_pred = [2.5, 0.0, 2.1, 7.8] mae = mean_absolute_error(y_true, y_pred) mse = mean_squared_error(y_true, y_pred) r2 = r2_score(y_true, y_pred) print(f"MAE: {mae:.3f}") print(f"MSE: {mse:.3f}") print(f"R2: {r2:.3f}")这段代码本身没什么难度,但它体现了回归评价的核心逻辑:你预测出来的值,跟真实值之间差多少。我建议你把这三个指标打包写成一个函数,后面每个模型跑完都调用一遍,这样横向对比模型效果就很方便了。
2.2 数据切分:训练集、验证集、测试集各司其职
回归代码里最容易被忽略的就是数据切分。很多人随手train_test_split一把梭,训练集测试集切完就开始训练。遇到调参场景,这套流程就会出问题:你用测试集调参,调来调去,测试集的信息早就泄露到模型里了。
第5天这个阶段,我建议你至少养成训练集/测试集二分的习惯。如果后面要调参,就加上验证集或者直接上交叉验证。
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 )这里random_state=42是老规矩了,固定下来方便别人复现结果。你换个随机种子可能模型分数就差0.02,但这不是模型变好了,是随机性带来的幻觉。所以做对比实验的时候,种子必须锁死。
2.3 特征缩放:什么时候必须做,什么时候无所谓的坑
回归代码里还有一道工序——特征缩放。要注意的是,不同模型对特征缩放的敏感度完全不同。
线性回归、岭回归、逻辑回归这类模型,对特征尺度很敏感。如果一个特征是房价(几百万量级),另一个特征是房间数(个位数量级),损失函数在梯度下降时大概率会震荡,收敛得又慢又差。所以这类模型必须在训练前做标准化或者归一化。
而树模型(决策树、随机森林、XGBoost、LightGBM)完全无所谓,因为它们做的是特征空间上的切分,特征的绝对大小不改变切分点的相对位置。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)这段代码背后有一个必须记住的细节:fit_transform只用在训练集上,transform直接用在测试集上。你在测试集上重新fit了,等于让模型偷偷看到了测试集的均值方差,这又是一次数据泄露。
3. 手写线性回归代码:从底层看清梯度下降在做什么
3.1 为什么不直接用sklearn,非要手写
我知道你心里在想:LinearRegression一行就搞定了,为什么要手写?因为直接用库有个后果——你永远不知道它背后在干什么。一旦遇到预测结果全是NaN或者模型训练不收敛的情况,你连怎么排查都不知道。
手写一遍线性回归,你会明白三件事:损失函数怎么定义、梯度怎么算、学习率怎么影响收敛。
3.2 手写代码逐行拆解
import numpy as np class LinearRegressionGD: def __init__(self, learning_rate=0.01, n_iterations=1000): self.learning_rate = learning_rate self.n_iterations = n_iterations self.weights = None self.bias = None self.loss_history = [] def fit(self, X, y): n_samples, n_features = X.shape # 初始化参数 self.weights = np.zeros(n_features) self.bias = 0 # 梯度下降迭代 for i in range(self.n_iterations): y_predicted = np.dot(X, self.weights) + self.bias # 计算梯度 dw = (1 / n_samples) * np.dot(X.T, (y_predicted - y)) db = (1 / n_samples) * np.sum(y_predicted - y) # 更新参数 self.weights -= self.learning_rate * dw self.bias -= self.learning_rate * db # 记录损失 loss = np.mean((y_predicted - y) ** 2) self.loss_history.append(loss) return self def predict(self, X): return np.dot(X, self.weights) + self.bias这段代码的核心就四个步骤:
- 初始化参数:权重全设0,偏置设0。
- 前向计算:
np.dot(X, self.weights) + self.bias算出预测值。 - 反向求梯度:
dw是损失对权重的偏导,db是损失对偏置的偏导。这里用的MSE损失,所以梯度长这样。推导过程不复杂:MSE对w求导,链式法则一路展开就是(2/n) * X.T.dot(y_pred - y),2被学习率吸收了。 - 参数更新:沿着梯度的反方向走一小步。
3.3 学习率踩坑记录
手写代码最大的坑是学习率。learning_rate=0.01不是万能的。我试过在小数据集上用0.1,loss直接炸到天文数字;用0.0001,迭代一千次权重几乎没动。
一个实用的判断方法:把loss_history打出来看看。如果loss在下降,说明学习率没问题;如果loss震荡或者变大,赶紧调小学习率。如果loss像蜗牛一样几乎不降,可以把学习率调大十倍试试。
gd = LinearRegressionGD(learning_rate=0.01, n_iterations=1000) gd.fit(X_train, y_train) import matplotlib.pyplot as plt plt.plot(gd.loss_history) plt.xlabel("Iteration") plt.ylabel("MSE Loss") plt.title("Loss Curve") plt.show()看到loss曲线平滑下降,手写的代码就说明写对了。
3.4 特征标准化之后再手写
手写线性回归有个必要前提:特征必须标准化。如果特征尺度差异太大,梯度下降会非常慢甚至震荡。所以手写版本的正确用法是:
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) gd = LinearRegressionGD(learning_rate=0.1, n_iterations=1000) gd.fit(X_train_scaled, y_train) y_pred = gd.predict(X_test_scaled) mae = mean_absolute_error(y_test, y_pred) r2 = r2_score(y_test, y_pred) print(f"MAE: {mae:.3f}, R2: {r2:.3f}")这样跑下来,手写版本和sklearn的LinearRegression结果应该非常接近。如果差很多,说明你代码里有bug,这也是手写一遍最有价值的地方——提前发现bug,而不是等到复杂模型里再去头疼。
4. 正则化回归:岭回归和Lasso的代码逻辑
4.1 什么时候你该考虑正则化
线性回归有一个非常明显的死穴:当特征之间高度相关(多重共线性)或者特征数量接近样本数量的时候,权重会变得非常大,模型方差飙升。你在训练集上拟合得很好,一到测试集就崩盘。
解决办法就是正则化。岭回归(Ridge)在损失函数里加了一个L2惩罚项,Lasso加了L1惩罚项。通俗理解,就是告诉模型:"别把权重搞那么大,差不多得了。"
4.2 岭回归代码速写
from sklearn.linear_model import Ridge, Lasso ridge = Ridge(alpha=1.0) ridge.fit(X_train_scaled, y_train) y_pred_ridge = ridge.predict(X_test_scaled) lasso = Lasso(alpha=0.1) lasso.fit(X_train_scaled, y_train) y_pred_lasso = lasso.predict(X_test_scaled) print("Ridge R2:", r2_score(y_test, y_pred_ridge)) print("Lasso R2:", r2_score(y_test, y_pred_lasso))alpha是惩罚项的强度。alpha越大,权重被压缩得越狠。这个参数怎么调?最省事的方法是画一条alpha从很小到很大的曲线,横轴是alpha对数,纵轴是交叉验证分数,找到峰值对应的alpha。
from sklearn.model_selection import GridSearchCV import numpy as np param_grid = {"alpha": np.logspace(-3, 3, 20)} ridge_cv = GridSearchCV(Ridge(), param_grid, cv=5) ridge_cv.fit(X_train_scaled, y_train) print("Best alpha:", ridge_cv.best_params_)4.3 为什么实际项目里岭回归用得多
Lasso有一个特性:它能把部分权重压成0,相当于自动做特征选择。听起来很美好,但实际回归任务里,Lasso对特征尺度很敏感,而且当特征间相关性很强时,它可能随便挑一个留下,删掉另一个,导致特征选择结果不稳定。
我个人的经验是:如果你只是想解决共线性问题、稳定模型预测,优先用岭回归;如果你确实想筛特征,且特征相关性不太强,再考虑Lasso。这篇"DAY5"的内容我没把ElasticNet放进来,它结合了两种正则化,后面遇到实际问题时可以再深入研究,理解原理之后上手很简单。
5. 回归树与随机森林回归:从一棵树说起
5.1 回归树和分类树的本质区别
决策树从根节点往下分裂,回归树和分类树的分裂依据不一样。分类树用基尼系数或信息增益找最优划分,回归树用方差找最优划分。回归树希望每个叶子节点内部的y值尽可能接近,也就是方差尽量小。
简单理解:回归树把特征空间切成若干块,每块里所有样本的y值取平均作为预测值。块切得越多,模型越复杂,越容易过拟合。
5.2 单棵回归树代码
from sklearn.tree import DecisionTreeRegressor tree = DecisionTreeRegressor(max_depth=3, min_samples_leaf=5) tree.fit(X_train, y_train) y_pred_tree = tree.predict(X_test) print("DecisionTree R2:", r2_score(y_test, y_pred_tree))max_depth=3限制树的深度,防止它无限制地长下去。min_samples_leaf=5限制叶子节点的最小样本数。这两个参数是回归树防过拟合的核心旋钮。
5.3 随机森林:为什么要多个模型一起预测
单棵树的问题很明显:它对数据中的噪声非常敏感,稍微换一批训练数据,树的结构就完全变了。随机森林的思想就是:同时训练很多棵结构不同的树,每棵树用不同的随机样本(Bootstrap抽样)和不同的随机特征子集,最后把它们的预测值取平均。
from sklearn.ensemble import RandomForestRegressor rf = RandomForestRegressor( n_estimators=200, max_depth=10, min_samples_leaf=3, random_state=42, n_jobs=-1 ) rf.fit(X_train, y_train) y_pred_rf = rf.predict(X_test) print("RandomForest R2:", r2_score(y_test, y_pred_rf))这段代码里有几个参数值得说:
n_estimators=200:树的数量。不是越多越好,一般100到500之间就能稳定。加多了训练速度变慢,效果提升有限。max_depth=10:每棵树的最大深度。深度太深容易过拟合。n_jobs=-1:用所有CPU核心并行训练,省时间。
5.4 怎么看随机森林的特征重要性
随机森林有个天生特性:它能在训练完后告诉你每个特征对预测的贡献度。这在实际业务场景中非常有用,可能是整个项目里最能落地的一个功能。代码实现很简单:
importance = rf.feature_importances_ for name, imp in zip(feature_names, importance): print(f"{name}: {imp:.4f}")特征重要性数值加起来等于1,数值越大说明该特征对预测的贡献越高。你可以据此筛掉那些重要性接近0的特征,简化模型。但有一点要注意:特征重要性不代表因果性,它只说明这个特征和数据目标存在较强的相关性。
6. 梯度提升回归:分步搭建LightGBM与XGBoost
6.1 随机森林和梯度提升的最大区别
随机森林每棵树是独立训练的,大家投票取平均。梯度提升则是一棵树接着一棵树,每一棵新树都在学习前面所有树留下的残差。
打个比方:你要预测房价,第一棵树预测出100万,真实值是120万,差20万。第二棵树就来学习这20万的差值,它的预测值是18万。现在总预测是118万,还差2万。第三棵树继续学这个2万……每一轮都在补之前的漏洞。
6.2 XGBoost回归代码
import xgboost as xgb model_xgb = xgb.XGBRegressor( n_estimators=300, max_depth=6, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model_xgb.fit(X_train, y_train) y_pred_xgb = model_xgb.predict(X_test) print("XGBoost R2:", r2_score(y_test, y_pred_xgb))参数说明:
n_estimators:树的数量。梯度提升的树太多会过拟合,所以要配合早停。learning_rate:学习率,每棵树的贡献被缩小的程度。学习率小,需要的树就多,但泛化能力通常更好。subsample:每一轮训练随机采样80%的样本,增加随机性,防过拟合。colsample_bytree:每棵树用80%的特征,和随机森林的"特征子集"思路一致。
6.3 LightGBM回归代码
LightGBM是另一个高频使用的梯度提升框架,最大的优势是训练速度快,在特征多、数据量大的场景下非常受欢迎。
import lightgbm as lgb model_lgb = lgb.LGBMRegressor( n_estimators=300, max_depth=-1, learning_rate=0.05, num_leaves=31, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model_lgb.fit(X_train, y_train) y_pred_lgb = model_lgb.predict(X_test) print("LightGBM R2:", r2_score(y_test, y_pred_lgb))LightGBM和XGBoost的核心参数高度相似,但有个关键区别:max_depth=-1表示不限制深度,而是靠num_leaves控制树的复杂度。叶子数越多,模型越复杂,也越容易过拟合。我的建议是先从num_leaves=31开始,然后根据验证集的效果增减。
6.4 梯度提升必须掌握的早停方法
梯度提升的树是串行叠加的,树太多会过拟合。早停就是每训练完一轮,在验证集上看效果,如果连续多少轮没有提升,就提前停止训练。
model_xgb = xgb.XGBRegressor( n_estimators=1000, learning_rate=0.05, early_stopping_rounds=50, random_state=42 ) model_xgb.fit( X_train, y_train, eval_set=[(X_test, y_test)], verbose=False ) print("Best iteration:", model_xgb.best_iteration)注意:early_stopping_rounds这个参数在新版XGBoost里也可以放到fit方法里传。关键是eval_set必须给,没有验证集就没法判断"是否提升"。
7. 逻辑回归:名为"回归",实际是分类算法
7.1 逻辑回归和线性回归的血缘关系
逻辑回归挂着"回归"的名字,但它实际做的是分类。它在线性回归的输出上套了一层Sigmoid函数,把任意实数压缩到0到1之间,变成概率值。
Sigmoid长这样:
import numpy as np def sigmoid(z): return 1 / (1 + np.exp(-z))逻辑回归的损失函数也不是MSE,而是对数损失(Log Loss),也叫交叉熵损失。它的好处是在概率接近0或1时仍然能给模型足够大的梯度信号。
7.2 逻辑回归代码
from sklearn.linear_model import LogisticRegression clf = LogisticRegression(C=1.0, max_iter=1000) clf.fit(X_train, y_train) y_pred_clf = clf.predict(X_test) y_proba = clf.predict_proba(X_test)[:, 1]这里的C是正则化强度的倒数,C越小正则化越强。逻辑回归里我强烈建议你把max_iter设大一点,比如1000,否则默认值在某些数据上会报"不收敛"警告。
7.3 什么时候选逻辑回归
逻辑回归的输出是概率,这对很多业务场景非常重要,比如风控模型需要一个违约概率阈值来决策。同时它极其可解释,每个特征的系数就是该特征对目标的对数几率贡献。在需要给业务方讲清楚"为什么判这个客户通过"的场景,逻辑回归比任何树模型都好用。
我个人的实践倾向是:如果项目需要可解释性,或者预测结果要对接概率决策系统,优先逻辑回归;如果纯拼精度、数据量又大,树模型和梯度提升更合适。
8. 回归实战中跑代码最常见的坑:完整排查链路
最后这部分是整个DAY5里最值钱的。我把自己在回归代码上踩过的坑整理成了一套排查顺序,你按照这个顺序检查,能省下大量报错幻觉的时间。
8.1 预测结果全是一个常数
如果你发现预测值无论输入什么特征都是同一个数,大概率是以下两个原因之一:
- 模型没有收敛,权重还停在初始值附近。解决办法:加大迭代次数或者调整学习率,也可以检查特征是否标准化。
- 数据里目标变量的方差本身就极小,模型学到的最优策略就是预测均值。这种情况要回到业务端确认预测任务是否有意义。
排查手段很简单:打印模型预测的前20个值,看看它们有没有波动。
8.2 训练集分数极高,测试集分数崩盘
这是典型的过拟合。树模型很容易出现这种情况,尤其是max_depth设置得很深。应对办法:
- 降低
max_depth和min_samples_leaf - 调大
subsample的采样率(实际上是减少每棵树用的样本量) - 加入正则化参数,比如XGBoost的
reg_lambda和reg_alpha - 更根本的方法是增加训练数据量
8.3 损失函数变成NaN
这个坑通常出在手写梯度下降或者深度学习框架里。原因一般是学习率太大,梯度更新一步直接导致数值溢出。解决办法:调小学习率,检查特征里有没有无穷大或空值。
8.4 特征里有字符串或缺失值
sklearn的很多模型不能直接处理字符串特征和空值。很多报错信息都是这个原因。解决办法是提前对特征做编码和缺失值填充。
# 缺失值处理 from sklearn.impute import SimpleImputer imputer = SimpleImputer(strategy="median") X_train = imputer.fit_transform(X_train) X_test = imputer.transform(X_test)8.5 随机森林和XGBoost结果差得离谱怎么办
先在同一个数据集上跑一个最简单的线性回归,看R²是多少。如果线性回归和随机森林分数差不多,说明数据本身的线性关系比较强,上复杂模型没有意义,直接选解释性最好的模型就行。如果线性回归很差而随机森林很好,说明数据里有复杂的非线性关系,这时候才值得继续优化梯度提升模型。
这是我个人比较依赖的模型选择顺序:先用基线模型摸清底数,再逐步增加模型复杂度,每一步都能定位到是数据问题还是模型问题。
9. 一个小习惯:把今天的回归代码整理成自己的模板
学完这些代码,我强烈建议你不要只是看一遍就翻页。回归作为机器学习里最基础也最实用的一支,值得你沉淀出一个自己的"标准工作流"。具体来说:
- 写一个统一的评估函数,输入预测值和真实值,返回MAE、MSE、R²
- 写一个数据集划分的固定函数,带上
random_state参数 - 把线性回归、岭回归、随机森林、XGBoost、LightGBM这几个模型的训练与评估脚本整理到一起,方便后面切换对比
第5天这个阶段,真正拉开差距的不是谁代码写得花哨,而是谁能把基础模型的适用范围、优缺点和代码落地点串成一条线。把这些回归代码逐一跑通、理解每个参数的含义,你后面学深度学习里的回归任务时,会发现很多思想其实是一脉相承的。