ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

二手车价格预测实战:基于GBDT与特征工程的422方案解析

二手车价格预测实战:基于GBDT与特征工程的422方案解析 天池长期赛的二手车价格预测我前前后后刷了好多轮。这个赛题数据量不大但字段覆盖了二手车交易场景里最常见的维度注册时间、行驶里程、功率、变速箱、车身类型、品牌车型、是否未修复损伤、上架地区、上架时间目标就是预测成交价格。价格这个目标本身呈明显的长尾分布几万块到十几万块的车占多数但也有上百万的高价车所以很多选手会掉进“高价车预测不准”的坑。我在这轮长期赛里迭代了四百多个实验版本最终提交的422方案拿到了一个自己比较满意的分数。这篇就把这个方案从数据预处理、特征工程到模型融合和踩坑记录完整拆开讲全程可复现。1. 赛题理解与方案整体设计422方案的好成绩建立在什么之上1.1 赛题任务与业务背景二手车价格预测本质上是一个回归问题输入一批车辆属性输出一个连续价格。天池长期赛这份数据训练集在一万五千行左右测试集几千行对打比赛来说是个很小的数据量好处是单机CPU就能跑坏处是模型很容易过拟合尤其是面对高基数的车型名称和品牌字段时稍不留神就把训练集的噪声学进去了。从业务角度理解二手车的价格逻辑其实非常清晰车龄、里程、品牌保值率、车况损伤、车身类型、排放和燃油类型这些因素共同决定一辆车在二手市场能卖多少钱。很多新手一上来就疯狂构造特征反而忽略了这些业务常识。422方案里我做的一个重要决定是先把字段背后代表的市场逻辑搞清楚再决定做什么特征而不是把能想到的衍生特征全堆进去。1.2 422方案到底是什么为什么叫422方案不是因为刻意凑数而是我保留了每次实验的版本快照最后提交的这组配置恰好落在第422个实验版本上。这背后没有玄学就是反复迭代之后线上分数和线下稳定性都到了我个人满意的平衡点。整个方案的技术栈非常朴素Pandas NumPy做数据处理LightGBM、XGBoost、CatBoost三个梯度提升树做主力模型五折交叉验证做线下评估最后在log空间做模型融合。没有用深度学习也没有堆太大的特征体系。不是深度学习不好而是这个数据量下结构化表格数据里GBDT的效率和稳定性都更划算。流程上可以概括成四步清洗数据、构造特征、五折训练、模型融合。每一部分都有细节要注意后面我逐个展开。1.3 评估指标与优化方向的取舍赛题的评估指标是MSE也就是预测价格和真实价格的均方误差越小越好。MSE对误差取了平方意味着预测偏差大的样本会被放大而那些几十万甚至上百万的车一旦预测偏差10万对整体分数的影响可能抵得上一百辆普通车的误差。所以422方案的第一件事就是对价格做log1p变换。简单说就是先把价格取对数在log空间里做回归最后再还原。这样做的原因是价格分布严重右偏直接回归会强迫模型优先拟合高价车而log变换之后优化目标从“绝对误差”变成了接近“相对误差”的东西模型会均衡地照顾各个价位段。这个决策直接影响后面所有特征和模型的选择。后面讲交叉验证和融合的时候还会反复提到这个log空间的概念。2. 数据探索与预处理修好数据和理解业务同样重要2.1 字段梳理与业务映射拿到数据先别急着建模把所有字段过一遍搞清楚每个字段的类型、缺失率、取值个数和业务含义。这步看起来琐碎但决定了后面所有特征工程的走向。字段类型业务含义与处理建议SaleIDint唯一ID直接丢掉nameobject车型名称高基数需要特殊编码regDateint注册日期格式YYYYMMDD转时间特征modelint车型编号类目特征brandint品牌编号类目特征bodyTypefloat车身类型有缺失用-1填充fuelTypefloat燃油类型有缺失用-1填充gearboxfloat变速箱类型有缺失用-1填充powerint发动机功率存在0值异常kilometerfloat行驶里程单位万公里数值特征notRepairedDamageobject是否有未修复损伤含“-”标记regionCodeint地区编码类目特征不做one-hotsellerint卖家类型取值单一删除offerTypeint报价类型取值单一删除creatDateint上架日期格式YYYYMMDD与regDate配合使用pricefloat目标价格分布右偏需log变换这里面比较有迷惑性的是notRepairedDamage字段。它里面的缺失状态是用字符“-”表示的直接读进来是object类型如果不处理模型会把它当成一个单独的类别等于引入了一个无意义的取值。另一个坑是seller和offerType我统计了一下这两个字段在训练集里几乎只有一个取值对预测起不到区分作用留着只会增加噪声直接删掉。2.2 缺失值和异常值处理二手车数据里缺失值往往带有业务含义。比如bodyType、fuelType、gearbox的缺失可能是该车型的参数本身就不公开或者录入时遗漏这种情况下我会统一填充成-1让树模型自己去学这个分组的规律而不是用均值去硬填。notRepairedDamage字段的处理更讲究。它里面的“-”占比非常高按业务理解“有未修复损伤”才记1“无损伤”记0而“-”大概率等于“无修复记录”对二手车的定价来说就是“没有未修复损伤”的信息。所以我把它替换成0再转成int类型。这里不能直接把“-”改成NaN然后填充否则信息量就丢了。power字段有不少0值这显然不符合真实车辆情况。我是这样处理的先看0值占比如果很少就直接用整体或同brand、同model分组的中位数填充。实际跑下来用分组中位数比整体中位数线下能低一点点原因也好理解——不同品牌、不同车型的功率基准差异很大用同组车的功率去估计缺失值信息更准确。另外regionCode在训练集和测试集上的取值分布有差异这是这个赛题一个隐藏的坑。很多特征一旦把regionCode直接细分很容易在训练集上表现很好但线上分数反而下降。422方案里我没有对regionCode做任何one-hot或目标编码只是让它作为一个弱特征参与训练测试下来最稳。2.3 价格分布与log变换的必要性把price画个直方图就能看到数据严重右偏大部分车集中在5万到20万之间但尾巴一直拖到100万以上。如果直接在原始价格上回归模型为了保证整体MSE最小会把大量容量花在拟合尾部高价车上而中低价车的预测精度反而被牺牲。log1p变换之后价格分布接近正态中低价区的细节也被拉平了。这里要注意的是在log空间训练出的预测结果最终要执行expm1还原成真实价格还原回来的预测值理论上不会是负数但偶尔个别样本会出现接近0甚至略小于0的情况所以后处理阶段要再做一次clip把预测值截断到合理区间。我强调一下log变换不是万能的它对MSE的优化有没有实际帮助可以用一个简单的交叉验证对比来判断。我在422方案早期做过对比实验同样的特征和模型log后训练的线下MSE明显低于直接回归所以这个选择是经过验证的而不是想当然。3. 特征工程全流程这4类特征撑起了422方案3.1 时间特征车龄是二手车定价的核心二手车圈有一句话叫“新车落地打八折”。车龄是影响价格最直接、最稳定的因素所以在时间特征上花再多功夫都不过分。原始数据里有regDate注册时间和creatDate上架时间我先把它们转成datetime类型然后计算两者差值得到使用天数再除以365.25得到使用年数。这个字段在模型重要性排序里稳居前三基本是所有特征里最硬的一个。除了使用年数我还提取了注册年份regYear和上架年份creatYear。注册年份能反映车型的出厂代际很多品牌在改款之后价格体系会变化这个信息藏在年份里面。上架年份反映的是二手车交易时的市场行情比如整体车市的供需变化。这两个年份特征直接作为数值特征喂给模型而不是当作类别因为年份天然有顺序关系数值越新代表越新的车。还有一个细节直接用creatDate减regDate时有些行会出现负值或者异常大的天数这是脏数据。我在422方案里把使用天数小于0的样本直接改成0超过50年的也做了截断避免这些极端值干扰模型。3.2 高基数类别特征name字段的目标编码技巧name字段是车型名称基数非常高光这个字段就有几千个取值。直接做one-hot会制造出大量稀疏列在十几万样本上几乎没有模型能消化好直接做label encoding也不行树模型会给每个编号一个分裂点等于把毫无顺序关系的车型名当成了有序数值。我用了两套方案来处理name字段。第一套是统计特征先算每个name在训练集里出现的次数把这个频次作为特征放进模型。车型越常见市场流通性强价格往往更稳定这个特征能捕捉到“热销车型”的信息。第二套是折外目标编码这也是高基数类别特征处理的核心手段。思路是用name这个分组下的price均值来替代原始的name字符串编码。但直接对整个训练集计算分组均值再编码会造成严重的数据泄漏——模型在训练时已经看到了当前样本自己的目标值信息线下分数会虚高线上崩盘。正确的做法是交叉验证形态的目标编码把训练集分成五折对每一折用其他四折的price均值来编码当前折的name这样当前样本的目标值就不会泄漏进自己的编码里。测试集则统一用整个训练集计算得到的name编码。为了平滑冷门分组我加了smooth参数让样本量少的分组编码向全局均值收缩。import numpy as np import pandas as pd from sklearn.model_selection import KFold def target_encode_with_oof(df, col, target, n_fold5, seed42, smooth20): df df.copy() global_mean df[target].mean() kf KFold(n_splitsn_fold, shuffleTrue, random_stateseed) encoded np.full(df.shape[0], np.nan) for tr_idx, va_idx in kf.split(df): tr df.iloc[tr_idx] stats tr.groupby(col)[target].agg([mean, count]) code (stats[mean] * stats[count] global_mean * smooth) / (stats[count] smooth) encoded[va_idx] df.iloc[va_idx][col].map(code).values stats_all df.groupby(col)[target].agg([mean, count]) full_code (stats_all[mean] * stats_all[count] global_mean * smooth) / (stats_all[count] smooth) return encoded, full_code这段代码的逻辑是先对训练集做折外编码保证编码过程中没有用当前折的目标值再把全量的训练集统计结果拿出来用于测试集的编码。对于测试集里出现的新namemap不到代码就直接用全局均值填充。这个技巧在特征工程阶段给线下CV带来的提升非常明显。3.3 数值特征加工与统计聚合特征kilometer行驶里程是另一个核心数值特征。二手车里“里程少、车况好”是定价的关键但这个特征在数据里分布非常集中大量车的里程集中在5到10万公里之间。我尝试过对里程做log、做分箱、做归一化最后发现树模型里直接喂原始数值效果最好因为GBDT能自己找分裂点人工分箱反而限制了它的表达能力。power功率同样如此。我做过一个改进是用每个样本的power和同brand、同model组的功率中位数做差值构造一个“功率是否显著高于同级车”的特征。这个特征比原始功率本身更能反映车辆配置的性价比对价格有正向区分作用。统计聚合特征是提高模型上限的关键。我会按brand分组统计价格均值按model分组统计价格均值按brand和bodyType组合统计里程中位数。这些统计量像先验知识一样把同一个细分群体里的共性信息注入了每个样本。这些特征的原理是一个新样本如果本身信息不足比如name是冷门车型但它的brand统计特征和model统计特征能提供足够强的参考信息模型就能给出相对合理的预测。需要控制聚合特征的数量。422方案里我最终保留了30多个特征统计聚合特征不超过10个。聚合特征太多容易相互抵消而且一旦某些分组在测试集上缺失这些特征就会变成噪声。3.4 特征重要性检查与筛选每次迭代完特征集合我都会用LightGBM跑一遍feature_importance把最重要的20到30个特征给拉出来看一遍。这不仅是检查特征有没有生效更重要的是可以发现泄漏特征。如果某个特征重要性高得离谱比如price相关的一些统计量在测试集上无法稳定计算我会优先怀疑它是不是间接泄漏了目标信息。特征筛选的方法也很朴素逐个添加特征看五折交叉验证的MSE是否稳定下降如果增加了某个特征后线下分数没有提升甚至下降就把它丢掉。这个“爬山式”的特征迭代策略看起来笨但在小数据集上非常有效能避免一次性引入大量特征导致的评估混乱。4. 模型训练与融合细节交叉验证怎么设才算稳4.1 五折交叉验证的设定与线下评估口径交叉验证是打比赛的生命线。422方案里我固定了KFold(n_splits5, shuffleTrue, random_state42)所有特征筛选、超参数调整、融合权重确定都在这同一套折上评估保证每一次改动的影响是可比较的。这里有个经验不要频繁更换随机种子或折的划分方式。如果你每次新增特征后都换一个新的交叉验证划分来看结果你就永远分不清分数的变化到底来自于特征改动还是来自于划分的随机波动。固定一套划分让所有实验在同一起跑线上对比虽然可能对最终分数有一点点偏向但它能让你做出更稳定、更可靠的决策。线下评估时我是在log空间里直接计算MSE的也就是对log1p后的预测结果和log1p后的真实价格计算均方误差。这样做的好处是模型训练的损失函数和评估指标是一致的线上提交的频率也少一些主要靠线下CV来把关。4.2 三个GBDT模型的调参记录422方案里选了三个模型原因很简单LightGBM快XGBoost稳CatBoost对类别特征友好三者之间的差异能带来融合收益。LightGBM是我每天调参的主力。关键参数如下lgb_params { objective: regression, metric: mse, learning_rate: 0.03, num_leaves: 63, max_depth: 7, feature_fraction: 0.8, bagging_fraction: 0.9, bagging_freq: 1, lambda_l2: 5.0, min_data_in_leaf: 30, verbosity: -1, }学习率我固定用0.03配合早停轮数200。先不要一开始就用太小的学习率训练时间会拉得太长调参效率太低。等到参数组合基本确定再调低学习率跑最终版本。XGBoost的参数也差不多eta0.03, max_depth6, subsample0.9, colsample_bytree0.8, alpha1, lambda5。XGBoost在这个数据上表现稳定但训练速度比LightGBM慢不少所以我主要是把它作为融合的一个备选模型。CatBoost最大的特点是能直接吃类别特征我试着把brand、model、name这些原始类别字段直接传进去指定为categorical_features。效果比手动目标编码略好但也更慢。实际使用时需要非常小心直接传入高基数类目会导致训练时间急剧增加建议先用折外目标编码处理再作为数值特征传入CatBoost这样速度和效果能兼顾。三个模型在五折交叉验证下的表现大概这样log空间的MSE模型线下MSElog空间训练耗时LightGBM0.0521较快XGBoost0.0526中等CatBoost0.0523较慢等权融合0.0515-融合带来的提升很明显不是某一个单模型能追上的。4.3 模型融合与预测值后处理融合策略上我测试过加权平均、排序平均甚至用线性回归在OOF预测结果上学习权重。最后发现在这个数据集上简单加权平均的效果和复杂学习权重差不多而且前者更稳。最终使用的权重是LightGBM 0.4、XGBoost 0.3、CatBoost 0.3这个权重不是拍脑袋定的而是用小范围搜索在一个独立验证集上选出来的。融合的位置也很重要。我是在log空间先对三个模型的预测做加权平均再统一expm1还原价格。因为在log空间里每个模型的预测误差近似正态分布加权平均能更好地抵消噪声如果先还原价格再融合几个模型在高价区的极端误差可能会被叠加放大。最后一步是后处理。预测值还原后我对所有结果做了clip截断到0和训练集price最大值之间。这个操作对MSE的影响非常小一般在0.001以内但它能保证提交结果符合业务常识不会出现负价格这种明显的问题。另外还试过一个后处理方向对OOF预测残差按name做分组修正线下CV能降一点点但线上完全不稳定。原因在于高基数类别下测试集里的冷门name在训练集里几乎没有样本支撑残差均值的估计方差太大纯属在拟合噪声。最终我放弃了这个思路。5. 踩坑记录一个版本一个版本迭代出来的教训5.1 线上分数和线下CV对不上这个问题在长期赛里最容易让人心态崩溃。特征加了线下CV漂亮地降了提交一跑线上反而涨了。我遇到的最典型情况就是目标编码泄漏。早期我在做name的目标编码时直接用全量训练集的统计值线下交叉验证看起来顺风顺水结果线上分数越到后面越虚。后来才意识到这叫静态目标编码当前样本的价格信息已经被编进了自己的特征里交叉验证里这个泄漏被掩盖了到了测试集上就原形毕露。从那以后我给自己定了一条规矩凡是涉及目标值的特征一律用折外编码并且把编码逻辑封装成统一函数宁可多花时间也不用临时写裸代码。5.2 那些“看似有效”但实际掉分的操作我把一些试过但被否决的操作列出来帮大家避坑操作线下CV线上效果分析对power做log变换无明显变化略降GBDT对单调变换不敏感删除notRepairedDamage的“-”样本明显下降下降信息量被删掉了对价格做分箱分类再回归明显上升上升回归任务被硬掰成分类得不偿失对regionCode做目标编码小幅下降不稳测试集分布差异导致过拟合按name做残差修正小幅下降不稳高基数下估计方差太大这几个操作踩过之后我对特征工程的态度务实了很多凡是提升不能稳定复现的特征都直接删掉不犹豫。5.3 复现与工程化注意点长期赛的好处是可以反复提交坏处是如果不做好版本管理你可能永远在同一个坑里打转。我建议至少做到三点。第一每个实验版本都保存一份特征表而不是只保存代码。特征工程的代码可能在不同版本里改了又改但只有你当时实际喂给模型的特征表才能复现那一次的成绩。第二记录每一次实验的线下分数、线上分数、特征列表和关键参数。可以用一个简单的CSV文件来记录。时间长了你会发现真正有效的提升不是某一次猛操作而是十几次改进累积出来的。第三训练时间也要控制。这个赛题数据量小LGB一折几分钟跑五折也不慢但XGBoost和CatBoost如果参数不合适可能一组实验就要半小时。我建议先用少量迭代快速验证特征有效性确认有提升再喂足早停轮数。关于数据格式还有一个细节regDate和creatDate在读取时最好加上format参数且用errorscoerce处理脏字符否则一旦遇到非日期格式整行就会变成NaN影响下游特征计算。我个人的体会是天池这种长期赛拼的并不是谁的trick最多而是谁的数据处理更干净、特征验证更严谨。422方案里没有一个大杀器唯一算得上技巧的也就是折外目标编码和log空间融合但这套组合在迭代了几十个版本之后线下线上表现都非常稳定。对新手来说这个赛题是一个很好的练习场建议从头到尾完整跑一遍这个方案你会对特征工程和交叉验证的配合有非常直观的理解。最后再分享一个小技巧每次跑完实验务必把特征重要性和预测结果截图存档下次调参的时候回看能少走很多弯路。
返回列表