二手车价格预测实战:基于GBDT与特征工程的422方案解析
2026/9/16 9:54:56 网站建设 项目流程

天池长期赛的二手车价格预测,我前前后后刷了好多轮。这个赛题数据量不大,但字段覆盖了二手车交易场景里最常见的维度:注册时间、行驶里程、功率、变速箱、车身类型、品牌车型、是否未修复损伤、上架地区、上架时间,目标就是预测成交价格。价格这个目标本身呈明显的长尾分布,几万块到十几万块的车占多数,但也有上百万的高价车,所以很多选手会掉进“高价车预测不准”的坑。我在这轮长期赛里迭代了四百多个实验版本,最终提交的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-hot
sellerint卖家类型,取值单一,删除
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_fold=5, seed=42, smooth=20): df = df.copy() global_mean = df[target].mean() kf = KFold(n_splits=n_fold, shuffle=True, random_state=seed) 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

这段代码的逻辑是,先对训练集做折外编码,保证编码过程中没有用当前折的目标值;再把全量的训练集统计结果拿出来,用于测试集的编码。对于测试集里出现的新name,map不到代码,就直接用全局均值填充。这个技巧在特征工程阶段给线下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_splits=5, shuffle=True, random_state=42),所有特征筛选、超参数调整、融合权重确定,都在这同一套折上评估,保证每一次改动的影响是可比较的。

这里有个经验:不要频繁更换随机种子或折的划分方式。如果你每次新增特征后,都换一个新的交叉验证划分来看结果,你就永远分不清分数的变化到底来自于特征改动,还是来自于划分的随机波动。固定一套划分,让所有实验在同一起跑线上对比,虽然可能对最终分数有一点点偏向,但它能让你做出更稳定、更可靠的决策。

线下评估时,我是在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的参数也差不多,eta=0.03, max_depth=6, subsample=0.9, colsample_bytree=0.8, alpha=1, lambda=5。XGBoost在这个数据上表现稳定,但训练速度比LightGBM慢不少,所以我主要是把它作为融合的一个备选模型。

CatBoost最大的特点是能直接吃类别特征,我试着把brand、model、name这些原始类别字段直接传进去,指定为categorical_features。效果比手动目标编码略好,但也更慢。实际使用时需要非常小心:直接传入高基数类目会导致训练时间急剧增加,建议先用折外目标编码处理,再作为数值特征传入CatBoost,这样速度和效果能兼顾。

三个模型在五折交叉验证下的表现大概这样(log空间的MSE):

模型线下MSE(log空间)训练耗时
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参数,且用errors='coerce'处理脏字符,否则一旦遇到非日期格式,整行就会变成NaN,影响下游特征计算。

我个人的体会是,天池这种长期赛,拼的并不是谁的trick最多,而是谁的数据处理更干净、特征验证更严谨。422方案里没有一个大杀器,唯一算得上技巧的也就是折外目标编码和log空间融合,但这套组合在迭代了几十个版本之后,线下线上表现都非常稳定。对新手来说,这个赛题是一个很好的练习场,建议从头到尾完整跑一遍这个方案,你会对特征工程和交叉验证的配合有非常直观的理解。最后再分享一个小技巧:每次跑完实验,务必把特征重要性和预测结果截图存档,下次调参的时候回看,能少走很多弯路。

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

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

立即咨询