ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二手车价格预测实战:从RMSLE评估到LightGBM特征工程与模型融合

二手车价格预测实战:从RMSLE评估到LightGBM特征工程与模型融合

简介:面向大学生竞赛与课程设计的二手车价格预测优胜方案源码资料包,源自阿里天池与Datawhale联合举办的赛事,包含获奖方案的完整项目说明。压缩包共十五个文件,以Python脚本为主体,辅以md说明文档、txt依赖清单与少量备份文件,整体仅约610KB,体量轻巧,当前已有42人浏览学习。资料完整覆盖数据预处理、特征工程、模型训练与集成融合等竞赛核心环节,包含基于梯度提升树、神经网络及多模型加权融合等不同思路的建模代码,并附预测结果数据表,能够帮助使用者系统理解获奖方案的构建脉络并快速复现。适合计算机科学、应用数学、电子信息工程等相关专业的学生或科研人员,作为课程设计、毕业设计或学术竞赛的参考范本,其思路亦可迁移至房价、销量等同类回归预测问题。

1. 二手车价格预测竞赛:不是比模型,是比谁更懂二手车的定价逻辑

同一台车,有人估 9 万,有人报 11 万,差价背后不是玄学,而是对车龄、里程和品牌残值率的理解深度。某竞赛平台与某开源学习社区联合举办的二手车价格预测赛题,要求根据车辆的多维信息预测成交价,评估指标直接对标行业里的定价偏差。这类赛题的优胜方案通常不是最深的神经网络,而是把特征工程、交叉验证和多模型融合做到极致的一套树模型方案。这篇笔记会从数据认知、特征构造、模型训练讲到源码组织,完整拆解一套可复现的建模流程,适合想入门结构化数据竞赛的开发者,也适合正在做二手车估值系统的工程师。我们先从赛题数据本身开始。

2. 赛题拆解与数据认知:价格为什么是长尾分布,以及我们要预测什么

不少参赛者拿到数据后第一件事就是跑一个 LightGBM,但如果不先理解价格分布和评估指标,后续所有调参都是在黑匣子里瞎试。二手车价格预测本质上是一个回归问题,但它的特殊之处在于目标变量跨越一万元到上百万元,误差度量方式直接决定了你该优化什么。

2.1 任务定义与评估指标:你优化的不是误差,而是对数误差

官方在赛题说明里通常会把评估指标定义成 RMSLE(Root Mean Squared Logarithmic Error),也就是对真实价格和预测价格分别取对数后计算 RMSE。为什么要这样设计?因为一辆 3 万的车少估 3 千,和一辆 30 万的车少估 3 千,在绝对误差上是相同的,但在买卖双方的心理感知上完全不同。取对数后,3 万车少估 3 千对应 log 误差约 0.1,30 万车少估 3 千对应 log 误差约 0.01,这样就把“相对误差”作为惩罚核心,更贴近二手车定价业务。

所以在训练之前,我一般会先写一个 RMSLE 计算函数,用作所有模型验证的统一标尺。

import numpy as np def rmsle(y_true, y_pred): # 将真实值和预测值都限制在非负范围,避免对负数取对数 y_true = np.maximum(y_true, 0) y_pred = np.maximum(y_pred, 0) # 用 log1p 计算对数误差的均方根,等价于对价格取 log 后的 RMSE return np.sqrt(np.mean((np.log1p(y_true) - np.log1p(y_pred)) ** 2))

这段代码里有三个关键点:np.maximum 是为了防止极端预测值出现负价格而做兜底;np.log1p 是 log(x+1),在价格接近 0 时比 log(x) 更稳定;整个函数返回的是一个标量分数,分数越低代表相对误差越小。后续每一次交叉验证、特征筛选、融合权重调整,我都用这个函数来打分,不额外看别的指标。

明确了优化目标之后,还需要知道训练数据长什么样。这里要提醒一下,RMSLE 和 RMSE 的数值有很大差异,不要拿 RMSE 的标准去评判 RMSLE。

2.2 探索性数据分析:先看价格分布、缺失值和异常值,再谈建模

EDA 看起来是体力活,但往往决定了后续特征工程的走向。我用 pandas 加载训练集后,第一件事是看 shape、字段类型和描述统计,重点关注 price、mileage、reg_date 这三个字段的分布和缺失比例。常见的数据集里,品牌、车系、上牌日期、行驶里程、排量、变速箱、燃料类型、过户次数、是否事故车等字段都会出现,其中上牌日期和过户次数的缺失率往往不低,这部分要先记录下来。

价格分布是典型的右偏分布,少量豪华品牌的高价车占据长尾,如果直接用原始价格做回归,模型会被那些几十万的车主导,忽略了几万元区间的主流样本。所以我会同时画原始价格和 log 价格两个直方图,确认取对数后的分布是否接近正态。

import pandas as pd import matplotlib.pyplot as plt train = pd.read_csv('train.csv') print(train.shape) print(train.describe(percentiles=[0.01, 0.25, 0.5, 0.75, 0.99])) plt.hist(train['price'], bins=100) plt.yscale('log') plt.show() train['log_price'] = np.log1p(train['price']) plt.hist(train['log_price'], bins=100) plt.show() missing = train.isnull().sum() print(missing[missing > 0])

上面这段代码里,train.describe 的 percentiles 参数会额外给出 1% 和 99% 分位数,方便快速发现极端价格;plt.yscale('log') 让长尾更加直观;最后打印的 missing 列表是后续填充策略的依据。如果看到 price 的最小值是 0 或者接近 0,大概率是样本异常或者数据采集错误,需要在特征工程阶段处理。

一个常见的数据集字段说明可以整理成下面这样,方便后续写代码时对照。

字段名类型业务含义预处理关注点
brand_idint品牌编码是否需要做目标编码
reg_datedatetime上牌日期计算车龄
mileagefloat行驶里程(万公里)缺失值、异常值
powerint最大功率(马力)缺失填充
gearboxstr变速箱类型转类别编码
fuel_typestr燃料类型转类别编码
transfer_countfloat过户次数缺失用 unknown
pricefloat成交价(万元)仅训练集有,需取对数

从这张表可以直观看出,真正需要手工处理的字段并没有多少,但这不代表建模简单。price 和 mileage 之间是明显的非线性关系,和 reg_date 之间存在车龄的单调衰减,这些关系都靠特征工程去表达。

2.3 从原始字段到有效特征:车龄、里程、品牌残值率的构造逻辑

原始字段很少能直接进模型。日期字段必须转成车龄,里程数要考虑单位是否统一,品牌这种高基数类别特征需要找到一种既能保留信息又不过拟合的编码方式。

车龄的构造很简单:用数据采集年份(或者销售年份)减去上牌年份。为什么不用上牌日期本身?因为树模型无法理解“2017 年”这个数字的大小与价格衰减的关系,但车龄“5 年”是一个连续变量,模型可以学出“前 3 年贬值最快、5 年后贬值放缓”这种分段关系。里程也是一样,单纯用“里程×车龄”不如“年均里程”更能反映车辆的使用强度。

品牌残值率是这类赛题最重要的特征。不同品牌的三年保值率可能从 45% 到 75% 不等,这种差异比单一品牌 ID 更有解释力。常见做法是统计每个品牌在训练集中的平均成交价,作为该品牌的价格基准。

import pandas as pd import numpy as np train['reg_year'] = pd.to_datetime(train['reg_date']).dt.year train['reg_month'] = pd.to_datetime(train['reg_date']).dt.month train['car_age'] = train['sale_year'] - train['reg_year'] train['mileage'] = train['mileage'].astype(float) train['mileage_per_year'] = train['mileage'] / (train['car_age'] + 1) brand_mean_price = train.groupby('brand_id')['price'].transform('mean') train['brand_price_mean'] = brand_mean_price

car_age 单位是年,sale_year 一般来自数据集提供的销售年份字段;如果没有,可以考虑用数据发布时间。mileage_per_year 的计算里加了一个 1,是为了避免新车车龄为 0 时出现除零错误,同时也平滑了分母。brand_price_mean 用 transform 一次性映射到每一行,方便后续直接作为特征。

这里要注意,brand_price_mean 是用全量训练集统计出来的,如果直接用于交叉验证,会出现标签泄漏:某条样本的预测值里包含了它自己所在品牌的平均价格信息。正确做法是在每一折内重新统计,或者使用后面会讲到的目标编码。我在这里先展示特征构造的思路,后面章节会给出防泄漏的实现。

数据认知到此为止,接下来进入特征工程和清洗环节。这个环节决定了你最终是站在排行榜中游还是上游。

3. 特征工程与数据清洗:决定名次的是特征,不是调参

特征工程是最能拉开排名差距的部分,而它也是最容易被新手忽略的部分。很多参赛者拿着同一个 LightGBM 参数跑完就去看排名,却不知道别人在特征上多花了两倍时间。

3.1 缺失值填充与异常价格处理:避免把事故车当正常车

缺失值处理常常被一句 fillna(0) 带过,但二手车数据里的缺失往往带有业务含义。比如过户次数缺失,可能是车商没有录入,也可能是数据源本身没有这项记录,如果直接用 0 填充,模型会学到“缺失=从未过户”这种错误信号。我习惯对类别型缺失单独标记一个 unknown 类别,让树模型自己去判断缺失与目标的关系。

数值型字段的缺失要分情况:排量、功率这类连续值,往往和车型定位相关,用中位数填充比均值更稳,因为中位数不受极端值影响。如果字段缺失率超过 40%,我倾向于直接剔除,而不是强行填充,因为填充一个半数以上都是缺失的字段,等于是在制造噪声。

价格异常值是另一个坑。有些样本成交价低到几百元,对应的却是正常描述的车况,这大概率是数据采集错误或者特殊交易情形。如果不处理,模型会为了拟合这些离群点而扭曲整个分裂逻辑。同时还要留意价格和里程的联合异常:比如里程只有 1 万公里但价格只有同款车的 20%,可能是事故车,如果数据集里带有事故车标记,可以直接利用。

train['transfer_count'] = train['transfer_count'].fillna('unknown') train['displacement'] = train['displacement'].fillna(train['displacement'].median()) train = train[(train['price'] > 0.5) & (train['price'] < 100)] train = train[~((train['mileage'] < 2) & (train['price'] < train['price'].quantile(0.1)))]

第一行把过户次数缺失变成独立的 unknown 类别;第二行用中位数填充排量,避免均值被大排量车型拉高。后面的过滤条件,我是按价格在 0.5 万到 100 万之间来筛,具体阈值根据赛题的价格分位数调整;最后一行过滤掉“低里程但极低价”的矛盾样本,这类样本十有八九是异常数据。过滤比例如果超过训练集的 5%,就要回头检查是否误杀了真实的特价车。

3.2 类别特征编码与组合特征:品牌、车系、变速箱如何进入树模型

类别特征的编码方式直接影响树模型的表达能力。简单高基数类别(比如车系)如果直接用 LabelEncoder,等于给车系赋予了一组任意整数编号,树模型会误以为编号之间有大小关系。LightGBM 支持直读类别特征,但需要在 Dataset 中指定 feature_name 和 categorical_feature 参数,否则也会自动按数值处理。

对于高基数类别,我更推荐做目标编码:用该类别下的价格均值作为特征。这样既编码了类别本身,又编码了该类别的价格水平。但目标编码必须防泄漏,不能直接在全体训练集上计算均值,否则验证分数会虚高。

from sklearn.model_selection import KFold def target_encoding(df, col, target, n_splits=5, seed=42): kf = KFold(n_splits=n_splits, shuffle=True, random_state=seed) df['te_' + col] = np.nan for tr_idx, va_idx in kf.split(df): tr = df.iloc[tr_idx] va = df.iloc[va_idx] mean_map = tr.groupby(col)[target].mean() df.loc[df.index[va_idx], 'te_' + col] = va[col].map(mean_map) # 测试集中出现但训练集中没出现过的类别,用全局均值兜底 df['te_' + col] = df['te_' + col].fillna(df[target].mean()) return df

这个函数的逻辑是:把数据分成 5 折,对每一折都用其余 4 折的类别均值去填当前折的对应取值。这样每一行的目标编码都只依赖不在这行本身的历史信息,从机制上避免了泄漏。n_splits 用 5 还是 10 取决于数据量,数据量在几万条以上时 5 折足够,折数越多偏差越小但计算越慢。seed 固定后每次运行结果可复现。

除了编码,组合特征也有很大作用。我一般会构造品牌和车龄的交互、品牌和里程的交互,以及排量和变速箱的组合。品牌残值率明显不同,而同一品牌下,5 年 10 万公里的车和 5 年 5 万公里的车价格差可能超过 15%,这种差异只有通过交互特征才能被树模型捕捉到。

train['brand_age'] = train['brand_id'].astype(str) + '_' + train['car_age'].astype(str) train['brand_mileage_clip'] = train['brand_id'].astype(str) + '_' + pd.cut(train['mileage'], 10).astype(str)

brand_age 是一个字符串拼接得到的组合类别,用来表达“某品牌在某车龄区间”的交叉效应;brand_mileage_clip 把里程分成 10 档后再和品牌拼接,相当于把里程分箱和品牌做了交互。这种手工组合特征在树模型里往往有奇效,因为它们比树模型自己找的 split 更加贴近业务逻辑。

3.3 价格对数变换与标签编码:让回归目标更接近正态分布

标签变换是最后一个关键动作。前面看到价格是长尾分布,直接把原始价格作为目标,模型在迭代中的梯度会被大价格样本主宰,导致小价格样本拟合不足。取对数后,目标分布变得对称,梯度更均衡。

在实现上,我会把标签转换和特征编码放在同一个流水线里:

from sklearn.preprocessing import LabelEncoder le = LabelEncoder() for col in ['gearbox', 'fuel_type', 'color']: train[col] = le.fit_transform(train[col].astype(str)) train['log_price'] = np.log1p(train['price']) feature_cols = ['car_age', 'mileage', 'power', 'displacement', 'gearbox', 'fuel_type', 'transfer_count', 'brand_price_mean', 'mileage_per_year', 'te_brand_id', 'te_model_id', 'brand_age'] X = train[feature_cols] y = train['log_price']

LabelEncoder 会把字符串类别转成从 0 开始的整数,对树模型足够用。注意要保证训练集和测试集使用同一个 LabelEncoder 对象,最好在训练完成后把训练数据中的类别到整数的映射保存下来,推理时复用,否则类别集合不同会导致编码错位。log_price 是后续所有模型训练的标签,模型预测出来的是 log 空间的数值,最终提交前要执行 np.expm1 还原成价格。

特征工程做完后,不要急着训模型,先检查一下特征之间是否有多重共线性或泄漏。比如 brand_price_mean 和 te_brand_id 都表达了品牌价格水平,两者高度相关,树模型虽然不怕共线性,但冗余特征会让重要性评估变得混乱。我的习惯是先跑一个 LightGBM 看特征重要性,把重要性很低且确实业务重复的特征删掉,这样后续模型收敛更快。

现在进入模型训练和验证阶段。这一章会告诉你为什么选 LightGBM、交叉验证怎么做才不失真、融合到底该加权还是 Stacking。

4. 模型训练与交叉验证:从单模型到融合,优胜方案是这样炼成的

特征做到位之后,模型选择变得相对简单。二手车价格预测这类表格任务,树模型是当之无愧的主力,而树模型里我优先选 LightGBM。

4.1 基线模型选择:为什么优先选 LightGBM 而不是线性回归

线性回归的问题在于它假设特征和目标之间是线性关系。二手车价格和里程的关系显然不是线性的:前 5 万公里折旧快,过了这个区间折旧放缓。要让线性模型 work,你需要手工构造分箱、交互、多项式特征,这个成本非常高。LightGBM 通过梯度提升和直方图分裂,自动学习分段常数函数,正好匹配这种非线性关系。

另一个理由是工程效率。LightGBM 的内存占用低,训练速度快,自带处理缺失值的逻辑,还支持类别特征直读。在同样的特征集上,LightGBM 的基线 RMSLE 通常比线性回归低 20% 到 40%,这个差距不是靠调参能追回来的。

我一般会先把下面这组参数作为起点,而不是一上来就去搜索超参数。

params = { 'objective': 'regression', 'metric': 'rmse', 'learning_rate': 0.05, 'num_leaves': 31, 'max_depth': -1, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l2': 1.0, 'verbose': -1, 'seed': 42, }

learning_rate 设 0.05 是比较稳的起点,如果想追求精度可以降到 0.02,但需要增大迭代次数。num_leaves 是主要复杂度控制参数,31 对应 2^5-1,适合中低维特征;如果特征很多,可以适当加大到 63 或者 127,但要注意过拟合。feature_fraction 和 bagging_fraction 都设 0.8,相当于每棵树只用 80% 的特征和 80% 的样本,是两种并行正则化。lambda_l2 加一点 L2 正则,让权重不要走极端。这些参数不是固定的,但作为基线已经能跑出不错的分数。

4.2 K 折交叉验证与早停:让验证分数真实反映线上表现

交叉验证是评估模型的唯一可信标尺。单次留出验证的波动太大,随机选一批数据当验证集,运气好就高,运气差就低。K 折交叉验证把训练集切成 K 份,每份轮流当验证,最后取 K 次验证分数的平均,这样能减少评估方差。

在二手车价格赛题里,数据通常不是严格的时间序列,随机 K 折是常见做法。但如果数据里有明显的采集日期字段,而且价格随时间有趋势性变化,就要改成按时间切分:用较早的数据训练,较晚的数据验证,否则模型会偷看未来信息。

import lightgbm as lgb from sklearn.model_selection import KFold from sklearn.metrics import mean_squared_error kf = KFold(n_splits=5, shuffle=True, random_state=42) oof = np.zeros(len(X)) for fold, (tr_idx, va_idx) in enumerate(kf.split(X)): tr_X, tr_y = X.iloc[tr_idx], y.iloc[tr_idx] va_X, va_y = X.iloc[va_idx], y.iloc[va_idx] dtr = lgb.Dataset(tr_X, label=tr_y) dva = lgb.Dataset(va_X, label=va_y) model = lgb.train( params, dtr, num_boost_round=5000, valid_sets=[dva], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(0)] ) oof[va_idx] = model.predict(va_X, num_iteration=model.best_iteration) score = np.sqrt(mean_squared_error(y, oof)) print(f'CV RMSLE: {score:.5f}')

这段代码有几个细节值得注意。dtr 和 dva 分属训练部分和验证部分,early_stopping 在验证集上连续 100 轮没有提升时停止训练,避免过拟合的同时不需要手动指定迭代轮数。predict 时加上 num_iteration=model.best_iteration,确保使用的是早停时的最佳模型,而不是最后一次迭代的模型。每折训练完都把对验证集的预测存进 oof,最后统一计算 RMSLE,这个分数就是模型在随机 K 折下的真实估计。

如果 CV 分数和线上分数差距很大,优先检查是不是交叉验证切分方式与赛题数据的生成方式不一致。比如赛题数据本身是分多个批次的,或者价格标签有时间聚集效应,这时候随机 K 折的 CV 分数会虚高。

4.3 多模型融合策略:加权平均与 Stacking 的取舍

单模型跑出合适的 CV 分数后,融合是往上走的常见手段。融合的基础是多样性:不同模型看到的数据视角不同,它们的错误模式不同,加权平均后误差会互相抵消。常见的组合是 LightGBM、XGBoost、CatBoost,也可以加入岭回归模型专门拟合线性部分。

简单加权平均是最稳的融合方式,实现成本低,不容易过拟合。权重不一定要精确搜索,我会先看每个模型单独的 CV 分数,分数越好的给越大权重。一般权重控制在 0.5、0.3、0.2 这种粗糙的比例即可,过度调权重反而是在拟合验证集。

pred_lgb = model_lgb.predict(test_X, num_iteration=model_lgb.best_iteration) pred_xgb = model_xgb.predict(test_X) pred_cat = model_cat.predict(test_X) final_pred = 0.5 * pred_lgb + 0.3 * pred_xgb + 0.2 * pred_cat final_price = np.expm1(final_pred)

这里三个模型都输出 log 价格,直接加权,最后 expm1 还原。如果你手头只有两个模型,权重可以改成 0.6/0.4。要注意的是,融合前一定要确认所有模型用的是同一份测试集特征,并且在相同的 log 空间里输出,否则加权没有意义。

Stacking 比加权平均更强,但风险也更大。做法是把每折 OOF 预测作为第二层的特征,然后用一个简单模型(比如 Ridge)去拟合真实标签。Stacking 的问题在于第二层很容易过拟合,尤其是训练数据量不足一万条时,融合收益会被过拟合吃掉。我的经验是:如果 OOF 分数提升不超过 0.001,就优先用加权平均;只有数据量超过 5 万条或者单模型差异足够大时,才值得上 Stacking。

下面是一个在实践中被反复验证过的结论:把交叉验证做扎实,比调 100 组超参数都有效。CV 分数和线上分数的相关性,是你判断一切特征和模型改动是否有效的唯一标准。只要 CV 和线上相关性好,你的每次改进才是真的改进,否则你只是在对着排行榜碰运气。

5. 二手车价格预测的 5 个常见坑:翻车现场与后悔药

前面讲的都是“正确做法”,但真正让你排位靠后的往往是那些看起来无害的处理习惯。这 5 个坑我都在复现这类赛题时踩过或者见别人踩过,每一个都配了后悔药。

5.1 标签泄漏:把“新车指导价”直接当特征

现象:交叉验证分数极低,单模型 RMSLE 能到 0.30 以内,自己都觉得要拿奖了,结果一提交线上分数比 CV 高出一大截,排名还不如随机猜测。

原因:数据里通常会有“新车指导价”或者“厂商建议价”这类字段。它确实是很有用的价格锚点,但它和目标成交价高度相关,甚至可以说指导价已经包含了价格信息。当你把指导价原封不动作为特征时,模型学到的是“成交价≈指导价×0.8”之类的映射,可测试集里同样有这个字段,所以训练时看似正确,但实际上模型并没有学习到车况、里程这些真正的定价因素。一旦线上数据的指导价和成交价关系发生漂移,立刻翻车。

解决:不要直接用指导价,把它变成“贬值率”或“价格折扣”再作为特征,贬值率=成交价/指导价,这样目标就和指导价解耦了。如果这个字段确实要保留,也要在交叉验证时做敏感性分析,看看去掉它对 CV 分数的影响。还有一种更保险的做法:凡是和目标强相关且业务上属于“结果”的字段,全部剔除。在复现阶段,我还会单独算一下指导价和成交价的斯皮尔曼相关系数,如果超过 0.9,就提醒自己一定要做变换。

5.2 缺失值填充太粗暴:用 0 填充类别特征

现象:模型不报错,特征重要性排名很高,CV 分数却上不去,而且在某些细分品牌上的预测严重偏离。

原因:0 在类别编码里成为了一个新类别,但缺失并不等于“无”或“手动挡”这类含义。以变速箱字段为例,如果缺失的车辆其实是自动挡,你填 0 后模型可能把“0”分裂成一个单独分支,专门预测一个奇怪的中间价格。树模型虽然能处理缺失,但那是基于模型自己的分裂决策,而不是你预先填充的武断值。

解决:类别字段的缺失统一用一个单独字符串 missing 填充,然后再编码;数值字段用中位数而不是 0 填充;如果是布尔型字段,比如“是否有事故”,缺失就填 unknown。这样做的核心思想是:让模型自己判断缺失模式与目标的关系,而不是强加一个业务含义。如果数据量大,我还会跑两个版本做对比,一个用 0 填充,一个用 missing 填充,看 CV 分数的差异是否明显,用数据说话而不是拍脑袋。

5.3 交叉验证与线上不一致:随机 K 折遇上时间漂移

现象:随机 5 折 CV 分数稳定在 0.25,线上分数却是 0.36,而且每次提交都稳定差 0.1 左右。

原因:赛题数据如果是按时间分批收集的,汽车价格会受市场行情、新车降价、政策变化等多种因素影响。随机 K 折把所有样本混在一起,等于让模型用后几个月的数据去“训练”前几个月的样本,或者反过来说,验证集里的样本在训练集里找到了来自未来的同类信息,CV 分数自然虚高。线上评估时用的是严格按时间划分的新数据,这种信息被切断,分数就崩了。

解决:先确认数据里有没有时间戳(比如上牌日期、采集日期)。如果有,按时间排序后对样本编号,取前 80% 做训练、后 20% 做验证。如果你用的是随机 K 折,至少保证 KFold 的 shuffle=True,并且固定 seed,方便排查。还有一个验证方法是:把数据按月份分组,画一张“每个月平均成交价”的曲线,看看是否存在明显趋势;有趋势就按时间切分,没有趋势才可以用随机 K 折。这个检查 10 分钟就能做完,却能避免后面几天的无效工作。

5.4 对数变换的陷阱:预测值取 exp 后系统性偏低

现象:RMSLE 看起来合格,但把预测价格从 log 空间还原后,发现平均预测值比真实平均值低 5% 到 10%,整体偏移。

原因:在 log 空间用 MSE 做优化,等价于拟合目标的对数几何均值。当你把对数均值变换回原始空间时,得到的是几何均值,而真实数据的均值是算术均值,两者天然存在差距。尤其当价格分布右偏时,这个差距会更明显。LightGBM 的 objective='regression' 直接作用于 log 目标,所以预测的 log 均值天然是偏低的。

解决:常见做法是做一个偏差校正系数。先用训练集每个测试样本的预测值乘以一个系数 c,c = 真实价格均值 / 预测价格均值,这个系数用 OOF 预测计算得到,然后套在测试集预测上。也可以尝试在训练时把目标换成原始价格,配合 huber loss 等对异常值鲁棒的损失函数,看 CV 分数的变化。我个人习惯用 OOF 校正系数,简单有效。具体实现时,我会把 OOF 预测的 exp 值和真实价格放在同一张表里,算一个加权平均系数,再把这个系数固化到 predict 脚本里,避免每次提交时手动改。

5.5 融合时权重拍脑袋,没有考虑相关性

现象:两个单模型 CV 分数都是 0.26,融合之后 CV 分数反而变成 0.27,还不如取最好的单模型。

原因:融合的前提是模型之间具有多样性。如果你把同一个 LightGBM 用不同的随机种子跑了两次,这两个模型的预测高度相关,错误模式几乎一样,加权平均不仅不会纠错,还可能把某些正确预测平均偏。如果再加一个 CatBoost,但它和 LightGBM 在特征使用上太接近,融合收益也有限。

解决:在融合前,先计算各模型 OOF 预测之间的皮尔逊相关系数。只保留相关系数低于 0.95 的模型进入融合;如果两个模型相关系数超过 0.97,说明它们几乎等价,留一个就行。权重不要纯靠感觉,可以用一个简单的网格搜索:在验证集上遍历权重组合,选出 CV 分数最低的组合,但要在多折上确认稳定性,防止过拟合验证集。我一般把权重搜索步长设为 0.1,组合数量控制在 100 以内,跑完后的最优权重如果和启发式权重差距很大,反而要警惕是不是验证集太小。

6. 把项目说明写成别人能复现的东西:源码结构、运行顺序与你的第一个改进点

一个能拿奖的方案,源码质量和项目说明同样重要。很多人跑完模型就丢在一堆 Untitled.ipynb 里,等到想复现的时候发现忘了哪步在前哪步在后。我习惯把代码拆成几个独立模块,每个模块只干一件事,然后通过 README 把整条流水线串起来。

car_price_prediction/ ├── data/ │ ├── train.csv │ └── test.csv ├── src/ │ ├── preprocess.py │ ├── features.py │ ├── train.py │ └── predict.py ├── notebooks/ │ └── EDA.ipynb ├── README.md └── requirements.txt

preprocess.py 负责加载数据、缺失值填充、异常值过滤;features.py 负责构造车龄、目标编码、组合特征,并输出特征矩阵;train.py 负责 K 折训练和保存模型,每个模型存成 pkl 或 txt;predict.py 读取模型,拼接测试集特征,输出提交格式。运行顺序就是 preprocess 到 features 到 train 再到 predict,每一步都从上一部的输出读数据,避免重复执行。

README 里我会写清楚四件事:赛题任务和评估指标、依赖库版本说明、一条命令从头训练到预测的执行方式、以及复现时需要注意的坑(比如目标编码必须用训练集的映射)。不需要写成论文,只要让另一个工程师照着做能在半小时内跑出和赛题排名对应的分数,就算合格。

如果你想在这个基础上做第一个改进,我建议从验证策略开始:把随机 K 折改成按时间切分,或者加入 GroupKFold 按品牌分组,往往比调模型参数更有效。

我在最初做这类赛题时,总把时间花在调参上,后来发现把验证策略做扎实比调 100 组参数都管用。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进