LightGBM做波士顿房价预测,这个项目我在不同阶段反复练过好几遍。说它经典,不光因为数据量小、上手快,更因为它把回归任务里最核心的流程——数据清洗、特征处理、模型调参、结果评估、多模型对比——完整串了一遍。这篇文章就把我用LightGBM模拟和预测波士顿房价的全过程梳理出来,重点放在每个环节为什么要这么做、参数怎么调、哪些坑我必须替你踩过。
如果你刚入门机器学习,正想找一个既能跑通完整流程、又不会因为数据集太大而卡在算力上的练手项目,这篇非常适合你。已经写过几版回归代码的老手也能从调参思路和避坑实录里找到点参考,尤其是小数据集上LightGBM特别容易过拟合的那些细节。
1. 项目背景与核心思路拆解
1.1 为什么波士顿房价这个数据集始终没被淘汰
波士顿房价数据集一共506条样本、13个特征、1个连续目标值MEDV,目标值本身也没有太离谱的分布偏移。放在今天动辄几十万上百万条数据的工业级场景里,它确实小得不起眼,但作为教学和实践数据集,它反而有不可替代的优势。
首先,506条样本意味着任何模型都能在几秒内跑完训练,你可以在一天之内把线性回归、决策树、随机森林、XGBoost、LightGBM全部试一遍,反复对比不心疼时间。其次,13个特征的类型足够丰富:CHAS是0/1二值变量,RAD和TAX带有明显的阶梯分布特征,RM和LSTAT是强连续的数值变量,这样的混合结构能让你同时练到数值特征和二值特征的预处理手法。更关键的是,房价这个业务场景大家都有直观认知,评估结果的好坏比较容易用常识判断——比如RM(平均房间数)大、LSTAT(低收入人口比例)小的区域房价高,这符合基本的居住逻辑,模型算出来对不对一目了然。
需要说明的是,这个数据集在新版scikit-learn中已经不再默认捆绑了,我复现时使用的是本地保存的CSV版本,文件名就叫boston_housing.csv,你也可以用公开的镜像文件直接读取,或者换成加利福尼亚房价数据集替代练习,流程完全一致。
1.2 LightGBM 凭什么能扛起这个任务
选LightGBM而不是简单跑个线性回归,是因为它在这类中低维表格数据上长期稳定地占据优势地位。作为梯度提升框架,它的核心思想还是加法模型:一棵树不行就再学一棵去拟合残差,步步逼近真实目标。但LightGBM在工程上做了三件重要的事,让它比早期GBDT好用得多。
第一是基于直方图的节点分裂算法。连续特征先被离散化成固定数量的直方图桶,分裂时不再对所有特征值逐个计算增益,而是直接统计桶内的梯度之和,训练速度大幅提升。第二是leaf-wise的叶子生长策略。传统GBDT按层展开,同一层所有节点都要处理,很多增益不高的节点白白浪费算力;LightGBM每次只挑损失下降最大的叶子去分裂,同样的树深度下精度明显更高,当然代价是更容易过拟合,所以后面必须配合max_depth和num_leaves的使用。第三是GOSS采样,训练时会优先保留梯度较大的样本,梯度小的样本被随机采样丢弃,在样本量大的场景里能省一大截训练时间。对波士顿这个506条的小数据集来说,速度优势其实体现得不太明显,真正有用的是它的精度上限和灵活的参数体系,这个我们放在第4节详细展开。
2. 环境准备与数据初探
2.1 环境搭建与依赖安装
这一步没什么玄学,Python 3.8以上版本都能跑。核心依赖就五个:pandas负责数据处理,numpy做数值计算,lightgbm是主角模型,scikit-learn用来做数据划分和评估,matplotlib和seaborn负责画图。直接装就行。
pip install lightgbm pandas numpy scikit-learn matplotlib seaborn装好后建议先跑一句验证LightGBM能正常导入,避免后面跑了半天才发现环境有问题:
import lightgbm as lgb print(lgb.__version__)我本机用的是4.x版本,如果你的版本是3.x,核心API差异不大,只是少数参数名在新版本里有默认值调整,后面讲到具体参数时会标注哪些地方容易因版本不同而踩坑。
2.2 数据集字段逐个看一遍
加载数据后别急着建模,先花十分钟把每个字段的含义和分布搞清楚。波士顿房价数据集各字段的含义可以整理成一张对照表:
| 字段 | 含义 | 类型 | 对房价的直觉影响 |
|---|---|---|---|
| CRIM | 城镇人均犯罪率 | 连续 | 越高房价越低 |
| ZN | 占地面积超2.5万平方英尺的住宅用地比例 | 连续 | 偏高可能说明区域更偏向大宅 |
| INDUS | 非零售商业用地占比 | 连续 | 工业占比高,居住环境差 |
| CHAS | 是否邻近河流 | 二值0/1 | 临河区域可能有溢价 |
| NOX | 氮氧化物浓度 | 连续 | 污染越高房价越低 |
| RM | 平均房间数 | 连续 | 房间越多面积越大,房价越高 |
| AGE | 自住房屋房龄占比 | 连续 | 老房多,房价偏低 |
| DIS | 到就业中心的加权距离 | 连续 | 越远可能越偏 |
| RAD | 到高速公路入口的便利指数 | 连续/阶梯 | 与TAX相关性很强 |
| TAX | 房产税率 | 连续 | 税率高区域房价承压 |
| PTRATIO | 师生比 | 连续 | 教育资源好坏的间接指标 |
| B | 区域黑人比例换算值 | 连续 | 这个特征历史上有争议 |
| LSTAT | 低收入人口比例 | 连续 | 比例越高房价越低 |
| MEDV | 房价中位数(单位千美元) | 目标值 | —— |
这里特别想多说一句LSTAT和RM。几乎所有跑过这个数据集的人都会发现,最终模型的特征重要性排名里这两个特征稳居前二。RM好理解,房间数直接反映房屋体量;LSTAT本质上是区域经济水平的代理变量,低收入比例低的地方,教育、治安、配套通常都更好,房价自然贵。这两个特征基本撑起了整个模型的预测能力,后面特征重要性分析时你们会亲眼看到。
2.3 数据分布快速体检
拿到数据先做一次体检,我习惯写一个几十行的脚本,把缺失值数量、字段统计量、目标值分布一次性打印出来。
import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns df = pd.read_csv('boston_housing.csv') print('数据集形状:', df.shape) print('缺失值统计:\n', df.isnull().sum()) print('目标值描述统计:\n', df['MEDV'].describe()) plt.figure(figsize=(8,4)) sns.histplot(df['MEDV'], bins=40, kde=True) plt.axvline(df['MEDV'].mean(), color='red', linestyle='--', label='均值') plt.title('MEDV 分布') plt.legend() plt.show()这份波士顿数据在公开版本里本来就是清洗过的,缺失值为0,直接复用即可。但注意MEDV的分布并不是完美的正态,右端有截断迹象,接近50左右的地方出现了一个平台。原因是这个数据集的MEDV做了上限处理,超过某个阈值的房子都被记成了同一个值,所以单纯追求最小化MSE的模型会对偏高房价段预测不足,属于数据本身的特性,不用强行修复。有强迫症的话可以只分析MEDV小于上限的子集,但常规练手没必要。
分布体检这一步的意义在于建立直觉:数据有没有缺失要处理、目标值偏不偏态、特征之间有没有强相关。这一步做的越仔细,后面预处理和调参就越有方向。
3. 特征工程与预处理实战
3.1 训练集与测试集划分的时序问题
预处理里最容易犯且最隐蔽的错误,就是先用全量数据计算统计量,再划分训练测试集。比如你打算对某个特征做标准化,先在全量数据上算均值和标准差,再切分数据,这看起来没什么问题,实际上已经把测试集的信息泄漏进了训练过程。
标准做法是先切分再做任何涉及全局统计的预处理。训练集内的缺失值填充均值、特征缩放参数,都只能用训练集的数据去计算,测试集再套用同一套参数。代码上就是先split再fit,顺序不能乱。
from sklearn.model_selection import train_test_split X = df.drop('MEDV', axis=1) y = df['MEDV'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) print('训练集样本数:', X_train.shape[0]) print('测试集样本数:', X_test.shape[0])这个random_state=42是固定随机种子,是为了保证每次跑出来的划分一致,方便复现和对比。有人会问,既然样本只有506条,测试集切20%会不会太小?对于练手项目,20%是常见选择,保证测试集有约100条样本,评估指标相对稳定。如果你追求更稳健的评估,后面可以换成5折交叉验证,那个我们调参环节再讲。
3.2 缺失值与异常值的处理策略
波士顿数据没有缺失值,但异常值依然值得关注。我习惯先画箱线图排查每个特征的离群点,重点看RM、LSTAT和CRIM。CRIM这个特征的分布极度偏斜,部分样本的值能到几十甚至上百,而中位数只有不到0.26,这种长尾分布如果不处理,对线性模型影响巨大,但对LightGBM这类树模型影响有限,因为树模型的分裂依据的是排序,而不是数值本身的大小。
所以这里有个取舍原则:如果你的基线模型里有线性回归,可以考虑对CRIM这类严重偏斜的特征做log变换;如果主角是LightGBM,保持原值反而省事。我做对比实验时的方案是保留原始特征跑LightGBM,只在跑线性回归对比时单独做标准化和偏斜处理,这样各模型都在自己最舒服的输入条件下公平竞争。
对于异常值本身,我建议不要轻易删除。506条样本本身就少,删几条可能显著影响模型对极端房价段的拟合能力。树模型对异常值的鲁棒性天然比线性模型好,极端值最多造成个别叶子分裂更细,不太会造成毁灭性影响。
3.3 标准化到底要不要做
这是新手最容易纠结的问题。直接给结论:对LightGBM,不需要做特征标准化。因为树模型的分裂点搜索基于特征值的相对排序,特征的单位、量纲、数值范围改变,排序不变,分裂结果也不变。标准化唯一可能带来轻微影响的地方是直方图分桶的边界位置,但实际测试下来差异微乎其微。
那为什么还是有很多教程在做标准化?因为很多人习惯把流程固定化,或者他们的对比模型里有SVM、KNN、神经网络这类依赖距离计算的模型,这些模型必须标准化否则结果会失真。我的建议是:你的主线是LightGBM,就不做;如果后面想加线性回归做对比,那就在单独的数据管道里对回归模型做标准化,各管各的,互不干扰。
有一条值得记住:特征缩放在树模型里是无效操作,但在特征重要性解释上反而可能有负面影响,因为标准化后的特征值失去了原始业务含义,你没法直接说"RM每增加1个房间,房价预期上升多少"。做业务类项目时,保留原始量纲对后续解释模型非常有价值。
4. LightGBM 建模与参数调优全流程
4.1 先用基线模型把流程跑通
任何调参工作都以一个能跑通的基线模型为起点。我的习惯是先全部用默认参数训练一版LightGBM,拿到一个初始指标,再逐步调优,每一步调整都能看到指标变化,这样才能明确知道哪个参数的改动真正起了作用。
import lightgbm as lgb from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score # 基线模型,全部默认参数 model_base = lgb.LGBMRegressor( random_state=42, verbose=-1 ) model_base.fit(X_train, y_train) y_pred_base = model_base.predict(X_test) rmse_base = mean_squared_error(y_test, y_pred_base, squared=False) mae_base = mean_absolute_error(y_test, y_pred_base) r2_base = r2_score(y_test, y_pred_base) print(f'基线RMSE: {rmse_base:.4f}') print(f'基线MAE: {mae_base:.4f}') print(f'基线R2: {r2_base:.4f}')默认参数下波士顿这个数据集的表现通常已经不错,RMSE在4.5到5之间,R2在0.85左右。这里注意一个细节:我用的是LGBMRegressor这个sklearn风格接口,而不是lgb.train的原生接口。前者用法和scikit-learn模型一致,方便和RandomForest、LinearRegression做对比;后者支持更细粒度的回调控制,适合进阶用户。新手用LGBMRegressor足够。
4.2 核心参数逐个拆解
LightGBM参数多,但真正决定模型性能的其实就这几类,整理成表格更直观:
| 参数 | 作用 | 默认值 | 调参方向 | 过拟合风险 |
|---|---|---|---|---|
| num_leaves | 单棵树最大叶子数 | 31 | 越小越保守 | 调大易过拟合 |
| max_depth | 树最大深度 | -1(不限) | 小数据集建议设置 | 不限深易过拟合 |
| learning_rate | 学习率 | 0.1 | 调小配多树 | 过大易震荡 |
| n_estimators | 树的数量 | 100 | 配合早停决定 | 过多必过拟合 |
| subsample | 行采样比例 | 1.0 | 0.7~0.9 | 调小可防过拟合 |
| colsample_bytree | 列采样比例 | 1.0 | 0.7~0.9 | 调小可防过拟合 |
| min_child_samples | 叶子最少样本数 | 20 | 调大更稳 | 过小易过拟合 |
| reg_alpha | L1正则系数 | 0 | 有稀疏特征可试 | 调大防过拟合 |
| reg_lambda | L2正则系数 | 0 | 建议从1开始试 | 调大防过拟合 |
这里重点解释num_leaves和max_depth的关系,这是很多新手第一个翻车的地方。LightGBM默认max_depth=-1,意味着树深度不受限,树的复杂度完全由num_leaves控制。和XGBoost的level-wise生长不同,LightGBM是leaf-wise,理论上num_leaves设置为31时,树可能长到接近5层,但不同分支的深度可以不一致。如果你同时设置了max_depth=5,那么实际叶子数不会超过2的5次方减1,也就是31,这种情况下你把num_leaves设成63就没有意义,因为深度限制了叶子上限。反过来,如果只设num_leaves不设max_depth,高风险分支可能长得很深,小数据集上极易过拟合。
我的建议是二者配合使用:max_depth设置在5到8之间,num_leaves设置在2的max_depth减1的80%以内,既给了模型足够的表达能力,又锁死了复杂度上限。
4.3 用交叉验证找到最优参数组合
确定参数组合最靠谱的方式是交叉验证。K折交叉验证把训练集切成K份,每次用K-1份训练、1份验证,轮流做K次,最后看平均指标。这样做的好处是每个样本都被当过验证数据,评估结果比单次划分更可信,尤其适合波士顿这种样本量小的数据集,单次划分的随机性可能掩盖真实的模型差异。
from sklearn.model_selection import cross_val_score # 使用5折交叉验证评估参数组合 model_cv = lgb.LGBMRegressor( num_leaves=31, max_depth=6, learning_rate=0.05, n_estimators=500, subsample=0.8, colsample_bytree=0.8, min_child_samples=20, reg_lambda=1.0, random_state=42, verbose=-1 ) # 负MSE因为sklearn的cross_val_score默认取最大 scores = cross_val_score(model_cv, X_train, y_train, cv=5, scoring='neg_mean_squared_error') rmse_cv = (-scores) ** 0.5 print(f'5折CV RMSE均值: {rmse_cv.mean():.4f}') print(f'5折CV RMSE标准差: {rmse_cv.std():.4f}')如果你有耐心,可以把候选参数组合写进GridSearchCV里自动搜索,但我更推荐分阶段手动调。先固定learning_rate不变,调num_leaves和max_depth;再调subsample和colsample_bytree;最后调正则参数。一次只动一个维度,能清楚看到每个参数在数据集上的边际贡献。波士顿数据量小,GridSearchCV跑起来不慢,但养成手动分步调参的思维习惯,对大项目的收益远大于自动搜索。
整个过程里,交叉验证RMSE标准差这个数字很容易被忽略,但它非常关键。如果5折的RMSE分别是4.8、5.2、4.5、5.4、4.9,均值5.0,标准差0.3,说明模型在不同子集上表现得比较稳定。如果标准差超过0.8,说明模型对特定样本极度敏感,泛化能力存疑,此时优先考虑增加正则和采样比例,而不是继续堆树的复杂度。
4.4 用早停防止树的数量失控
n_estimators设多少合适?答案不是某个固定数字,而是让模型自己决定。LightGBM支持early stopping,训练过程中监控验证集指标,连续若干轮没有提升就停止训练,并保留最优模型。
from sklearn.model_selection import train_test_split # 从训练集里再切一块验证集给早停用 X_train_inner, X_val, y_train_inner, y_val = train_test_split( X_train, y_train, test_size=0.15, random_state=42 ) model_es = lgb.LGBMRegressor( num_leaves=31, max_depth=6, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, min_child_samples=20, reg_lambda=1.0, random_state=42, n_estimators=2000, verbose=-1 ) model_es.fit( X_train_inner, y_train_inner, eval_set=[(X_val, y_val)], eval_metric='rmse', callbacks=[lgb.early_stopping(stopping_rounds=50)] ) print('最优迭代轮数:', model_es.best_iteration_)这里有个关于learning_rate的小门道:学习率越小,每棵树的贡献越微弱,需要的树就越多,但最终精度通常更好。0.1配几百棵树,0.05配上千棵树,0.01可能配到几千棵。和小数据集搭配时,学习率太低反而容易在训练集上反复打磨噪声,验证集指标还没到最优就已经开始过拟合。我的经验是0.05是波士顿这个数据集的甜蜜点,既不会因为学习率太高导致震荡,也不会因为太低而让训练时间意义不大地拉长。
要注意早停使用的验证集是从训练集里再切出来的15%,和最终的测试集严格隔离。这样做的目的是模拟真实场景中的验证行为,让你看到模型在没见过的数据上表现如何,同时又不碰测试集这个最终裁判。整套流程下来,你实际使用了训练集80%左右的样本做训练,15%做早停参考,最后20%测试集只用来报最终分数,数据边界非常清晰。
5. 模型评估与多模型横向对比
5.1 回归指标到底该怎么看
回归任务里MAE、RMSE、R2这三个指标最常用,但各有各的脾气。MAE是预测值和真实值绝对误差的平均,单位就是房价的千美元,解释起来最直观——模型平均预测偏了多少钱。RMSE对大误差更敏感,因为误差先平方再开方,一个偏离很大的预测值会把RMSE迅速拉高。如果MAE和RMSE差距明显,说明预测中存在一些误差较大的离群样本。
R2则是另一类指标,它衡量模型相对"用均值预测"这个最蠢基准提高了多少。R2等于0.9,可以理解为模型解释了90%的房价方差。对波士顿这个数据集,R2在0.85到0.92之间都是合理水平,过了0.95就要警惕是否过拟合或者数据泄漏了。
在实际报告结果时,我通常会给出RMSE和R2,MAE作为辅助参考。举一组我实际跑出来的数字来说明估算方法:某次模型RMSE约4.2,中位房价约21.2,RMSE占中位房价比例约为19.8%,意味着平均预测偏差接近两成房价,考虑数据本身的噪声水平,这个结果已经相当可用。
5.2 特征重要性背后的信号
LightGBM训练完成后,可以直接用plot_importance查看特征重要性图,也可以用model.feature_importances_拿到数值。LightGBM默认的importance类型是split,统计的是每个特征被选为分裂点的次数。另一种是gain,统计的是特征带来的信息增益总量。split容易被高基数特征带偏,gain更能反映特征对预测的实质贡献,所以建议手动把importance_type设为gain再比较。
import lightgbm as lgb # 设置importance_type为gain model_final = lgb.LGBMRegressor( num_leaves=31, max_depth=6, learning_rate=0.05, n_estimators=model_es.best_iteration_, subsample=0.8, colsample_bytree=0.8, min_child_samples=20, reg_lambda=1.0, random_state=42, importance_type='gain' ) model_final.fit(X_train, y_train) # 打印特征重要性 importance = pd.Series(model_final.feature_importances_, index=X_train.columns) importance.sort_values(ascending=False).plot.bar(figsize=(10, 5)) plt.title('LightGBM Feature Importance (gain)') plt.show()跑完之后不出意外,LSTAT和RM会牢牢占据前两位。往下看,DIS(到就业中心距离)、NOX(氮氧化物浓度)、PTRATIO(师生比)会紧随其后。这个排列顺序本身就是一份免费的领域洞察:波士顿房价最核心的驱动因素是区域经济水平和房屋本身的规模,其次是通勤便利度和环境质量。如果你做的是一个业务报告,特征重要性分析往往是比模型分数更容易打动人的交付物。
5.3 和线性回归、随机森林放在一张桌子上比
单独看LightGBM的分数没有说服力,放一起比才有意义。我习惯同时训练线性回归和随机森林作为对比组,用同一套训练测试集切分,最后汇总一张对比表。
from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor model_lr = LinearRegression() model_lr.fit(X_train, y_train) y_pred_lr = model_lr.predict(X_test) model_rf = RandomForestRegressor( n_estimators=300, max_depth=8, min_samples_leaf=5, random_state=42 ) model_rf.fit(X_train, y_train) y_pred_rf = model_rf.predict(X_test) results = { 'LinearRegression': { 'RMSE': mean_squared_error(y_test, y_pred_lr, squared=False), 'MAE': mean_absolute_error(y_test, y_pred_lr), 'R2': r2_score(y_test, y_pred_lr) }, 'RandomForest': { 'RMSE': mean_squared_error(y_test, y_pred_rf, squared=False), 'MAE': mean_absolute_error(y_test, y_pred_rf), 'R2': r2_score(y_test, y_pred_rf) }, 'LightGBM': { 'RMSE': mean_squared_error(y_test, model_final.predict(X_test), squared=False), 'MAE': mean_absolute_error(y_test, model_final.predict(X_test)), 'R2': r2_score(y_test, model_final.predict(X_test)) } } for name, metrics in results.items(): print(f'{name}: RMSE={metrics["RMSE"]:.4f}, MAE={metrics["MAE"]:.4f}, R2={metrics["R2"]:.4f}')以我多次跑这个数据集的经验,LightGBM和随机森林在波士顿上的差距通常在0.1到0.5的RMSE之间,LightGBM小幅胜出,而线性回归明显落后,RMSE通常偏高10%以上。这里有个值得思考的现象:随机森林在506条样本的小数据集上表现并不差,某些随机种子下甚至能和LightGBM打平。原因在于小数据集的噪声占比高,LightGBM的leaf-wise策略虽然拟合能力强,但也更容易抓住噪声;随机森林的bagging机制天然带有较强的去噪效果。这也提醒我们,在小数据场景里,模型复杂度和泛化能力的平衡点比"谁的理论精度上限更高"更重要。
顺带说一句,如果你想看预测值和质量的关系,画一张y_test对y_pred的散点图,理想情况下点应该贴在对角线上。波士顿这个数据集上你会看到中间段拟合很好,两端的点偏离对角线,右边低房价段预测偏高、高房价段预测偏低。这个是回归模型的典型表现,根源是样本量不足和数据本身的中位数回归倾向,不必过度修正。
6. 常见问题与避坑实录
6.1 数据泄漏是最容易犯的错
前面提了先划分再预处理,这里再强调一遍为什么这么重要。假设你在全量数据上做了标准化,再切分训练测试集,那么测试集的均值和方差信息已经参与了训练数据的预处理,评估分数会虚高。更隐蔽的是在特征选择时用了全量数据的相关性分析来挑特征,这也算一种泄漏。树模型内部对缺失值的处理也有类似风险,LightGBM支持在训练时自动学习缺失值的分裂方向,如果测试集缺失分布和训练集差异很大,预测会有偏差。
我在自己的流程里定了一条规矩:任何在训练集上拟合出来的统计量——均值、方差、填充值、缩放参数、特征选择结果——都作为管线的一部分,先用训练集fit,再对训练集和测试集统一transform。这句话写在笔记里,每次做项目都先过一遍。
6.2 关于max_depth和num_leaves的经典翻车位
新手的经典操作是看到默认max_depth=-1,觉得不合法,随手设成max_depth=3,然后发现模型效果反倒变差了。原因在于LightGBM的leaf-wise策略和深度绑定,如果你限制了深度却把num_leaves设得很大,比如max_depth=3、num_leaves=127,树根本长不出那么多叶子,参数等于白设,还可能因为内部逻辑矛盾产生难以解释的行为。
更稳妥的配置逻辑是:先确定max_depth,再设置num_leaves小于等于2的max_depth次方减1。比如max_depth=6,num_leaves最大63,但实际用31到47之间往往表现更好,因为并非所有叶子都需要长满。这个约束对任何规模的数据集都适用,区别只是具体数值范围。
6.3 缺失值到底该不该手动填
不少人有数据洁癖,见到NaN就想填均值、中位数、众数。但对LightGBM来说,缺失值不是必须处理的。LightGBM原生支持缺失值,训练时会自动学习缺失值应该走左子树还是右子树,而且这个分裂方向是数据驱动学出来的,不是简单填充能比的。你手动填了均值,反而可能引入不存在的虚假信息。
当然,如果缺失值比例很高,比如超过某一列的一半,我会先补一个专门的标记特征记录是否缺失,再用比如-1去填充原始列,这样模型既能利用缺失模式本身的信息,又能保留非缺失部分的真实值。这个技巧在真实业务数据里非常实用。
6.4 模型的复现性不是只靠random_state
LightGBM模型跑出来不稳定,换了环境重跑结果不同,这种问题在真实部署时特别头疼。random_state确实是关键,但LightGBM内部还有多个独立随机源:bagging_seed控制行采样的随机性,feature_fraction_seed控制列采样的随机性,drop_seed控制DART模式下的随机性。我们用的是普通的gbdt提升模式,不涉及drop_seed,但前两个最好也显式设置。
model_repro = lgb.LGBMRegressor( random_state=42, bagging_seed=42, feature_fraction_seed=42, ... )如果还觉得不够稳定,可以打开deterministic=True,这个参数会在直方图构建和特征分桶时强制使用确定性算法,代价是微小的训练速度损失。对波士顿这种小数据集完全无感,但放在大规模训练场景就要权衡一下。
还有一个小数据集的专属坑:单次train_test_split的随机性可能会让不同随机种子下测试分数波动很大,所以最终的分数报告我强烈建议用5折交叉验证的平均值而不是单次划分的结果。交叉验证的分数不骗人,片面的最好成绩才骗人。
我自己反复练这个项目最大的体会是:小数据集反而是检验模型功力的试金石。506个样本根本藏不住错误,数据泄漏、参数失衡、评估方法不当,任何一个问题都会直白地反映在分数上。很多人上来就堆参数,结果R2看起来很高,换一批数据立刻垮掉。真正练过这一遍的人会发现,把数据边界划分清楚、把预处理时序搞对、把评估指标吃透,比记住任何一组"最优参数"都更有价值。如果你刚跑通这个流程,下一步可以试试给LightGBM加上Optuna自动调参,或者改用加利福尼亚房价数据集把样本量放大到2万条,体会一下同样的模型在不同数据规模下的表现差异。模型本身是工具,理解工具在不同场景下的脾气,才是这个项目真正想教你的东西。