简介:针对天池二手车价格预测竞赛的高分项目资源包,面向参赛选手以及需要完成毕业设计、期末大作业的机器学习学习者,聚焦二手车交易价格这一回归预测问题。压缩包共十六个文件,含两个核心笔记本文件(分别实现LightGBM与XGBoost)、五个CSV数据文件(覆盖训练集、测试集与提交示例)、项目说明文档及依赖环境配置等,整体大小约三十二兆字节,目录结构清晰,运行前按说明配置环境即可。目前已有八百二十九人学习下载,验证了内容的实用性。除了两个梯度提升树模型的完整实现,资源还细致展示了缺失值填充、类别特征编码、交叉验证调参等特征工程与模型优化步骤,并保留最终预测结果提交文件,可帮助读者快速打通天池赛题从数据预处理到结果输出的全流程,是毕业设计或期末大作业中可直接参考的优质范例。
1. 二手车价格预测:天池竞赛里最适合照着复现的回归项目
不少人下载"天池竞赛二手车价格预测项目源码"回来却跑不起来——不是缺数据集,就是项目说明里没写清楚环境怎么装。这个项目的本质很简单:给你一份带几十个字段的二手车交易记录表,用结构化回归模型预测每辆车的成交价格,评测指标是 MAE。目标单纯,但数据里藏着缺失、异常和长尾,处理不好就是 0.7 和 0.55 的差别。这篇我按自己做过的路线讲:数据集怎么解析、特征怎么挖、LightGBM 怎么调、哪些坑必须避开。读完你不仅能跑通源码包,还能把这套流程套到自己手头的表格数据上。适合正在找实战项目练手、准备打天池或类似表格类竞赛的人。
2. 先读懂天池二手车数据集:字段、缺失与价格分布
2.1 字段结构:业务字段和匿名特征怎么区分
拿到项目包,第一步别急着训练,先打印 train.csv 和 test.csv 的表头。天池这场二手车比赛的字段结构是固定的:SaleID 是交易记录 ID,name 是车型名称编码,regDate 是注册日期(整数格式,比如 20040403),model 是车型编码,brand 是品牌编码,bodyType 是车身类型,fuelType 是燃料类型,gearbox 是变速箱类型,power 是发动机功率,kilometer 是行驶里程(单位万公里),regionCode 是地区编码,seller 和 offerType 是卖家和报价类型,creatDate 是数据建表日期,price 是我们要预测的目标价格。最后还有 v_0 到 v_14 一共 15 个匿名特征。
这里要先分清两类字段:前面这部分是能解释出业务含义的,后面 v_0~v_14 是官方脱敏后的匿名特征,我们不知道它具体代表什么,只能当数值特征用。很多高分项目的源码会把 v 系列直接丢进模型,也会拿它们做分桶、方差过滤和相关性筛选。项目说明里如果有字段字典,先核对一遍,确认有没有 price 这一列、train 和 test 的列是否完全一致。我见过有人上来就 pd.concat 两个表,结果把标签列混进特征里,训练时看着分数很好,提交时直接作废。
还有一点值得提前说:name、model、brand 这些字段虽然看着像 ID,但它不是文本名称,更不是 one-hot 后就能直接用的干净类别。dataset 里没有"奥迪 A6L"这样的真实字符串,业务直觉只能通过编码之间的频次、价格均值来间接使用。这决定了后面的特征工程思路——不能靠品牌知识,只能靠统计规律。
2.2 缺失值统计:train 和 test 要一起看
用 pandas 读进来以后,第一件事是统计缺失值分布。代码很简单,但有一个关键习惯:train 和 test 放在一起看,不要只看训练集。
import pandas as pd import numpy as np train = pd.read_csv('data/train.csv') test = pd.read_csv('data/test.csv') def missing_report(df, name): miss = df.isnull().sum() miss = miss[miss > 0].sort_values(ascending=False) rate = (miss / len(df)).round(4) report = pd.DataFrame({ 'column': miss.index, 'count': miss.values, 'rate': rate.values }) print(f"[{name}] shape={df.shape}, 缺失字段数={len(report)}") return report train_miss = missing_report(train, 'train') test_miss = missing_report(test, 'test')这段代码的逻辑很简单:对每个 DataFrame 统计每列空值数量,过滤出有缺失的列,按数量降序排列,同时算出缺失率。输出两个表的缺失字段对比。为什么要 train 和 test 一起看?因为填充策略直接影响线上预测稳定性。比如 bodyType 在训练集缺失率是 5%,在测试集缺失率是 8%,如果你只按训练集的 5% 决定"缺失就用众数填",落在测试集上就会有一批样本被填错,线上分数就会比本地差一截。
常见做法是把两个表纵向拼在一起统计,但拼之前要记住把 price 列去掉。一般我会再画一个简单的条形图看缺失率,这一步不需要多复杂,pandas 的 plot.bar 就够了。关键是心里有数:哪几个字段缺失严重、哪几个字段缺失模式可能和价格相关。比如 seller 和 offerType 在训练集里几乎只有一个取值,这种字段对预测没有区分度,后面可以直接删。
2.3 价格分布先做 log1p:不是玄学,是数学
再看目标列 price。直接用原始价格训练回归模型,MAE 会被高价车拖着走。二手车价格是典型的长尾分布——大部分车在 5 到 20 万之间,少数豪车能到 100 万以上。如果你用原始价格做损失,模型为了降低整体 MAE,会在 100 万的车身上花大量精力,导致普通价位预测精度下降。
解决方案在几乎所有高分项目里都能看到:对 price 做 log1p 变换,即 y = log(price + 1)。这样训练目标从"绝对价格"变成"价格的相对差异",更符合人对车价的感知——一辆 5 万的车差 5000 和一辆 50 万的车差 5000,主观上显然前者更严重,但绝对误差一样。log 变换把量级拉平后,模型更容易学到"同级别车型的价格规律"。
train['price_log'] = np.log1p(train['price']) # 训练完成后,预测值要逆变换回原价格 test['pred_price'] = np.expm1(test_pred)代码里的 np.log1p 对应 expm1,二者是严格互逆的,不会引入偏差。这里有一个容易被忽略的点:很多人训练时用了 log1p,但算验证集 MAE 时忘了逆变换,直接在 log 空间算误差,得到的分数量级偏小,看着好看,实际一提交就露馅。正确做法是先用 expm1 把预测值还原成价格,再和真实价格计算 MAE。
顺带说一句,test_pred 里的预测值是模型输出的 log 价格,如果你后面要做模型融合,融合应该在 log 空间做,还是还原到原始价格做?我的习惯是在 log 空间融合,因为每个模型的误差分布更接近正态,均值融合的稳定性更好。这一点到后面模型融合章节还会再提。
3. 特征工程:从注册日期、里程与匿名特征里挖出有效信息
3.1 车龄计算:把 regDate 转成 datetime 的三种写法
regDate 字段的格式是整数,比如 20040403 代表 2004 年 4 月 3 日。直接用这个整数当特征,模型只能学出"数字越大价格越高"的粗粒度规律,损失了大量时间信息。真正有业务含义的是车龄——车辆从注册到被交易之间隔了几年。
train['regDate'] = train['regDate'].astype(str).str.replace(r'(\d{4})(\d{2})(\d{2})', r'\1-\2-\3') train['regDate'] = pd.to_datetime(train['regDate'], format='%Y-%m-%d', errors='coerce') train['creatDate'] = train['creatDate'].astype(str).str.replace(r'(\d{4})(\d{2})(\d{2})', r'\1-\2-\3') train['creatDate'] = pd.to_datetime(train['creatDate'], format='%Y-%m-%d', errors='coerce') train['age_days'] = (train['creatDate'] - train['regDate']).dt.days train['age_years'] = train['age_days'] / 365.25这里用了正则替换把 20040403 转成 2004-04-03,再用 pd.to_datetime 解析。errors='coerce' 很关键,遇到类似 20040230 这种不存在的日期,会转成 NaT 而不是抛异常,省得后面排查半天。age_days 是精确到天的车龄,age_years 是折算后的年份,两个都放进特征池让模型自己选。
为什么说这是最重要的特征?你去翻天池排行榜上靠前的方案,几乎所有人的特征重要性前三位里都有车龄或它的衍生特征。二手车残值随时间衰减是非线性的——前三年贬值最快,后面趋于平缓。所以不少源码会进一步做分箱:把 age_years 按 [0,2)、[2,4)、[4,6)、[6,8)、[8,10)、[10,100) 切成几段,转成类别特征或直接让树模型去切分。我一般两种都保留,让 LightGBM 自己决定是当连续值切分还是当类别用,它对这个非常敏感。
3.2 高基数类别特征:model 和 brand 的统计编码
model 的取值有几百个,brand 有几十个,one-hot 编码会让特征矩阵爆炸,而且每个维度上的样本稀疏,树模型切分不稳定。常见做法是统计编码。第一种是频次编码:统计每个 model 在训练集里出现的次数,出现越多的车型说明保有量越大,二手价格往往更稳定。第二种是目标编码:统计每个 model 下样本价格(log 空间)的均值,用这个均值作为该车型的"基准价"特征。
model_stats = train.groupby('model')['price_log'].agg(['mean', 'count', 'std']).reset_index() model_stats.columns = ['model', 'model_price_mean', 'model_count', 'model_price_std'] train = train.merge(model_stats, on='model', how='left') test = test.merge(model_stats, on='model', how='left')这段代码的逻辑是:对 model 分组,计算每组 price_log 的均值、样本数和标准差,然后把统计量合并回 train 和 test。这里有一个非常关键的防泄漏细节:统计均值用的是训练集的 price_log,不是测试集的。测试集没有价格,你不能拿它参与统计。如果 train 和 test 在 merge 之前先 concat 再 groupby,均值的计算就混合了线上信息,测试集的特征里已经包含了来自未来数据的统计量,本地验证会高估模型表现。这就是典型的标签泄漏。
目标编码有个副作用:统计均值会和真实标签相关度过高,模型会把大部分权重压在这个特征上,导致泛化能力下降。缓解办法是加平滑——用全局均值与组内均值的加权平均,权重和样本数相关。样本数多就信组内均值,样本数少就信全局均值。很多竞赛选手用 category_encoders 库的 TargetEncoder,它自带平滑参数。我在实践中倾向于自己写,因为可以控制只对 model 和 brand 做,避免把 name(几百个取值)也套进去造成严重过拟合。
3.3 匿名特征 v_0~v_14:标准化、分桶与相关性筛选
v_0 到 v_14 这 15 个匿名特征是比赛最有意思的部分。官方没有给出含义,但它们和价格的相关性普遍不低。先做一个相关性矩阵,看看每个 v 和 price_log 的皮尔逊相关系数,再决定怎么处理。
v_cols = [f'v_{i}' for i in range(15)] for col in v_cols: corr = train[col].corr(train['price_log']) print(f'{col}: corr={corr:.4f}')从结果看,一般会有几个 v 字段相关系数在 0.3 以上,少数接近 0.6,这类是核心特征;也有几个差不多是噪声。我的做法是保留全部 v 字段进模型,但额外加入分桶后的类别版本。比如说把某个 v 列按十分位数切成 10 箱,转成整数类别喂给 LightGBM,让模型有机会学到非线性的小区间效应。分桶代码很简单:
for col in v_cols: train[f'{col}_bucket'] = pd.qcut(train[col], q=10, labels=False, duplicates='drop') test[f'{col}_bucket'] = pd.qcut(test[col], q=10, labels=False, duplicates='drop')pd.qcut 是按分位数等频切分,每个桶里样本数大致相同。注意切分的分位数要从 train 上计算,然后拿同样的边界去切 test,不能 train 和 test 分别切。否则两边桶的数值含义不一致,相当于特征分布漂移。这个在源码包里经常看到写错的版本:对 test 单独 qcut,结果同一个值在 train 里是 3 号桶,在 test 里变成了 4 号桶,模型推理直接错位。
标准化的做法也不难理解:部分 v 列可能有明显的偏态,log1p 后相关性会提升。这需要逐个试,不要一股脑全 log。我一般把标准化和分桶后的特征都保留,让树模型自己做选择,反正 LightGBM 对单调变换不敏感,多几个冗余特征影响很小。
4. 模型选型与调参:LightGBM 跑通最小流程
4.1 为什么选 LightGBM 而不是 XGBoost
表格类竞赛里 LightGBM 几乎成了默认配置。原因一是直方图算法训练快,二手车这种几十万行的数据,几个特征版本迭代起来一天能跑几十组实验,换 XGBoost 时间成本翻好几倍。原因二是它对类别特征有原生支持,model、brand 可以直接指定 categorical_feature,省去手动编码的麻烦。原因三是内存占用低,调参时开多进程不担心爆内存。
XGBoost 不是不能用,它的精度在某些场景下略好,但代价是调参空间大、参数之间关联性强。LightGBM 的核心参数少,默认值就接近可用,对新手友好得多。我在实战中为了冲榜,往往 LightGBM 和 XGBoost 都训练一份最后做融合,但第一版永远是 LightGBM 打底。就算项目源码里只提供了 LightGBM 部分,你把它拆开看懂,就能自己补一个 XGBoost 版本。
4.2 必调参数:五组参数先设定好再动手
表格如下:
| 参数 | 推荐初始值 | 作用与调参方向 |
|---|---|---|
| objective | regression_l2 | 回归任务默认,等价于 MSE |
| metric | mae | 和比赛评估指标保持一致 |
| learning_rate | 0.05 | 越小越准但越慢,配合 n_estimators 使用 |
| num_leaves | 31 | 控制模型复杂度,增大能提高拟合度但容易过拟合 |
| feature_fraction | 0.8 | 每棵树随机采样特征比例,防止过拟合 |
| bagging_fraction | 0.8 | 每棵树随机采样样本比例,配 bagging_freq=1 生效 |
| lambda_l2 | 1.0 | 正则化,特征多时加大到 5~10 |
这里最容易被忽略的是 metric 要设置成 mae。很多源码光写 regression_l2,验证集上打印的也是 l2 损失,跟比赛分数的口径对不上,导致你无法判断自己到底进步了还是退步了。我在项目里会把训练日志分别打印 l2 和 mae,心里只认 mae。num_leaves 和 max_depth 是一对,建议只调 num_leaves,把 max_depth 关掉或设一个大的上限,避免两个参数互相打架。
learning_rate 的选择和 n_estimators 是联动的。学习率设 0.05 时,一般需要 1000~3000 棵树才能收敛;设 0.01 就需要上万棵树。我建议先用 0.05 跑通流程,确认特征没有 bug 后再降到 0.03 做最终模型。低学习率配合早停是最稳妥的防过拟合手段。
4.3 训练与验证代码:K 折交叉验证加早停
import lightgbm as lgb from sklearn.model_selection import KFold X = train.drop(['price', 'price_log', 'SaleID'], axis=1) y = train['price_log'] kf = KFold(n_splits=5, shuffle=True, random_state=42) oof = np.zeros(len(X)) test_pred = np.zeros(len(test)) params = { 'objective': 'regression_l2', 'metric': 'mae', 'learning_rate': 0.05, 'num_leaves': 31, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l2': 1.0, 'verbose': -1 } for fold, (tr_idx, va_idx) in enumerate(kf.split(X)): x_tr, x_va = X.iloc[tr_idx], X.iloc[va_idx] y_tr, y_va = y.iloc[tr_idx], y.iloc[va_idx] model = lgb.train( params, lgb.Dataset(x_tr, y_tr), num_boost_round=5000, valid_sets=[lgb.Dataset(x_va, y_va)], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)] ) oof[va_idx] = model.predict(x_va, num_iteration=model.best_iteration) test_pred += model.predict(test.drop(['SaleID'], axis=1), num_iteration=model.best_iteration) / kf.n_splits train['pred_price'] = np.expm1(oof) mae = (train['pred_price'] - train['price']).abs().mean() print(f'OOF MAE: {mae:.4f}')整个流程分四步:5 折切分、折内训练与早停、折外预测收集、测试集预测取平均。early_stopping(100) 表示连续 100 轮验证集 mae 没有下降就停止,best_iteration 取出最优轮次做预测。这里有一个细节:callbacks 里不能用 show_standard 之类和 early_stopping 混着乱的旧 API,新版 lightgbm 统一走 callbacks 列表,如果发现版本不兼容,优先升级 lightgbm,而不是改代码。
验证集的 OOF 预测还原成原始价格后,和真实价格算 MAE 才是可信的本地分数。上面这段代码跑完,正常范围应该能到 0.55~0.60(具体取决于特征工程做到什么程度)。如果分数在 0.7 以上,多半是 log1p 逆变换出了问题,或者特征里混进了泄漏字段。
5. 避坑:高分项目复现与调优中的五个翻车现场
5.1 数据泄漏、缺失值填充与异常样本
第一个坑:统计编码时泄漏。现象是训练时验证集 MAE 只有 0.5,提交后线上分数差出一大截。原因是目标编码用的均值是在 train 和 test 合并后的全集上计算的,测试集的价格信息间接进入了特征。解决方法是严格隔离:所有基于标签的统计量,只允许在训练集内做,然后乘以平滑系数再合并到测试集。我习惯把这一步写成函数,确保特征工程从头到尾只接收 train_X、train_y、test_X 三个参数。
第二个坑:缺失值无脑填均值。现象是 bodyType、fuelType 这类离散字段用均值填充后,模型重要性排名里它们排得很高,但线上分数却变差了。原因是均值填出的小数点破坏了类别语义,树模型只能被迫用这个特征反复切分来拟合噪声。解决方法是离散字段用众数或单独填一个 -1 类别,连续字段用中位数而不是均值,并且构造一个 is_missing 的二值特征告诉模型"这个样本缺失了"。缺失本身往往带着信息。
第三个坑:极端价格样本不去重。现象是数据里有一批价格为 0 或者价格低得离谱的样本,把它们留在训练集里,模型被带着学偏。原因是我看过这个数据集是有重复交易的,同一辆车可能在不同时间被记录多次,价格还有些波动。解决方法是先按所有字段去重,再看价格分布把极端值单独挑出来。我一般把 price 小于 1000 的样本单独分析,如果是数据采集错误就删掉,如果是真实赠送类交易就对它降采样,避免让模型去拟合异常业务逻辑。
5.2 验证策略、融合方式与参数不一致
第四个坑:本地验证用随机 KFold,线上分数明显更差。现象是本地 OOF 稳定,提交后排名掉一截。原因往往在于时间依赖:creatDate 越晚的样本,价格规律越接近线上数据,随机切分让模型看到了"未来"的信息。解决方法是改成按 creatDate 排序切分,比如前 80% 做训练、后 20% 做验证,或者至少加一个按时间分层的 KFold 做对比实验。如果按时间切分后的分数和随机切分差很多,说明数据存在时间漂移,线上提交前要多留个心眼。
第五个坑:融合时直接对原始价格做算术平均。现象是两个单独分数都在 0.56 左右的模型,融合后反而变成 0.58。原因是两个模型在个别样本上的偏误差方向一致,均值融合没有抵消误差,反而把两边的误差都保留了。解决方法是先把每个模型的预测值都转成 log 空间,再加权平均,权重用验证集上的 OOF 误差倒数来确定。误差大的模型权重低,误差小的权重高,比等权平均稳定得多。另一个办法是用栈回归,拿 OOF 预测作为新特征训练一个简单岭回归,效果往往更好。
这五个坑几乎覆盖了我在源码复现里遇到的大部分翻车现场。看到别人高分项目的代码别急着跑,先看他特征工程的统计量是怎么算的、验证集是怎么切的。这两处干净了,分数才有参考价值。
6. 进阶:从 0.58 到 0.55,我最常做的三件事
第一件是后处理。LightGBM 预测完,我习惯按 model 分组看预测偏差:如果某个车型的预测整体偏高 3000 块,就说明这个组里有个别异常样本拉高了模型对该组的均值映射。这时候我按 model 分组的 OOF 残差均值做一次微调——把预测值减去该组残差均值的一半,相当于给模型输出加了一个小的纠偏项。别小看这一步,有时能砍掉 0.003 的 MAE,而且几乎不增加过拟合风险。
第二件是特征重要性的二次检查。训练完第一版,立刻打印 gain 维度的 feature importance,把排序前 10 的特征列出来。正常情况下车龄、power、kilometer、几个核心 v 字段应该在前面。如果 name 或者 model 的目标编码排到了第一且权重远高于其他特征,说明编码平滑系数太小,模型在走捷径。我会把平滑系数加大,重新训练对比验证集分数。
第三件是收敛验证:用低学习率 0.01 重训一次最好模型。这一步要跑很久,但通常能再压掉 0.005 左右。技巧是复用第一版模型的 best_iteration 作为参考,第二版直接设置 num_boost_round 为它的三倍左右,配合更严格的 early stopping(50 轮),避免等太久。
我做这些项目有个习惯:每调一次参数就把 OOF 分数和对应的特征列表记在注释里,方便回退。版本管理不是只给代码用的,实验结果也需要后悔药。天池二手车这个项目做到最后,比的已经不是模型复杂度了,而是特征工程细节和验证策略的严谨程度。你踏踏实实把数据流和验证流做干净,排名自然会回来。希望这些能帮到你,也祝你能在复现源码的基础上跑出自己的分数。
本文还有配套的精品资源,点击获取