天池二手车价格预测实战:特征工程、调参与模型融合
发布时间:2026/9/30 9:55:57来源:尧图网络
简介天池二手车价格预测竞赛完整项目包包含源代码、项目说明与数据集面向机器学习学习者、竞赛参赛者以及需要完成毕业设计或期末大作业的学生。项目使用Python语言围绕二手车交易价格回归预测任务提供了基于LightGBM和XGBoost的两种完整方案覆盖数据预处理、特征工程、模型训练、参数调优与结果提交等核心环节帮助读者系统掌握梯度提升树在价格预测中的应用。压缩包共十六个文件包括五个CSV数据文件训练集、测试集及提交样例、两个Jupyter Notebook源码文件、一个Markdown说明文档、依赖库清单以及项目配置文件等整体大小约三十二兆字节目录按代码、数据、提交结果清晰划分方便按步骤学习。目前已有八百二十九人学习下载。通过这套资源读者能获得一份高分竞赛的完整解题思路既可参照源码复现整个流程也可利用说明书与提交文件核验结果作为毕业设计、期末大作业或机器学习实战项目都很有参考价值。1. 天池二手车价格预测一个能写进简历的高分入门赛如果你在找第一个能完整写进简历的机器学习项目天池二手车价格预测是个很合适的起点。这个赛题给了一份脱敏后的二手车交易数据集需要用车辆属性字段预测成交价格。它不涉及图像、不涉及文本是纯表格回归任务用 LightGBM 或 XGBoost 就能跑到不错的分数但对数据清洗、特征工程和交叉验证的要求一点不少。这类项目的完整交付通常包含三块可直接运行的源码、讲清思路的项目说明、以及一份能跑通全流程的数据集。我把这套东西拆成数据理解、特征改造、模型调参、避坑和融合五个环节来讲读者里新手能一步步复现熟手也能对照检查自己的流程有没有踩多余的动作。2. 读懂赛题数据字段含义、缺失处理与价格分布拿到这类数据后我通常不会先跑模型而是先把数据集当成一个待修的黑匣子拆开看。这一步占整个项目至少三分之一的时间做不干净后面所有特征和模型都是在脏数据上盖楼。2.1 字段地图这份数据里到底有什么这份赛题数据的常见结构是数十万行记录每行是一辆车的挂牌信息核心字段如下表所示。我建议你也先做一张这样的字段地图再往下走。字段类型含义与踩坑点name字符串车辆名称类别数上千不能直接 one-hotregDate整数注册日期形如 20150101需要解析出年份model整数车型编码跨品牌可能重复brand整数品牌编码有低频类别bodyType整数车身类型存在缺失fuelType整数燃油类型存在缺失gearbox整数变速箱类型power整数功率存在大量 0 值kilometer浮点公里数单位通常是万公里notRepairedDamage字符串是否有未修复损伤含 - 占位regionCode整数地区编码seller整数卖家类型训练集和测试集分布差异很大offerType整数报价类型同上price浮点目标变量严重右偏v_0 到 v_14浮点匿名数值特征信息量极大seller 和 offerType 这两个字段是典型的坑训练集里几乎只有一个取值树模型靠它什么都学不到而测试集里可能完全是另一个取值。我一般直接删掉没有任何犹豫。regionCode 是地区编码可以保留让树模型自己去切但如果你打算做类别编码要注意测试集可能多出几个从未见过的地区低频地区要先合并掉。2.2 价格分布与评测指标为什么要对价格取对数先把价格分布画出来你会发现它不是正态的而是几百到几十万的长尾分布大部分车集中在低价区间少数豪车把右尾拖得很长。这类赛题常用的评测指标是 MSE也就是对每个样本的误差平方后求平均。在 MSE 下一辆 60 万的车预测成 50 万误差平方是 1 亿抵得上几百辆普通车的误差总和。如果不做任何处理模型会把几乎所有精力都花在拟合那几辆高价车上普通车的预测全乱掉。常见做法是对价格做对数变换让误差从绝对值变成相对值。import pandas as pd import numpy as np import matplotlib.pyplot as plt train pd.read_csv(train.csv) test pd.read_csv(test.csv) print(train[price].describe()) # 取log1p, 对0值友好, 还原时用expm1 train[price_log] np.log1p(train[price]) fig, axes plt.subplots(1, 2, figsize(12, 4)) axes[0].hist(train[price], bins100) axes[1].hist(train[price_log], bins100) plt.show()这段代码的逻辑是先把原始价格分布印出来确认长尾程度再取 log1p 画对比图。log1p 相当于 ln(1x)对价格里的 0 值不会产生负无穷。最终提交前要用 np.expm1 把预测结果还原成真实价格否则分数按原始价格计算时会完全对不上。2.3 缺失值、脏值和异常值先修数据再谈模型这个数据里最典型的脏值有两个notRepairedDamage 里的 - 占位符以及 power 字段里的大量 0 值。- 本身不是缺失是登记时没填写损伤情况但直接把字符串丢给模型不行要转成 NaN 或单独一类。power 为 0 大概率是信息登记错误因为一辆车功率不可能是 0。我的做法是先把 - 替换成 NaNpower 为 0 的也置为 NaN然后按品牌和车型分组取中位数回填。# - 转换为缺失, 树模型原生支持NaN, 也可以保留 train[notRepairedDamage] train[notRepairedDamage].replace(-, np.nan) test[notRepairedDamage] test[notRepairedDamage].replace(-, np.nan) # power为0视为缺失 for df in [train, test]: df.loc[df[power] 0, power] np.nan # 按 brand model 分组回填中位数 group_median train.groupby([brand, model])[power].median() for df in [train, test]: key df[brand].astype(str) _ df[model].astype(str) df[power] df[power].fillna(df[brand].map(group_median.to_dict())) # 注册日期转年份 for df in [train, test]: df[reg_year] (df[regDate] // 10000) df.loc[df[reg_year] 2017, reg_year] np.nan这里不能在整个数据集上算中位数回填测试集因为 train 和 test 各自分布有差异更稳妥的是在交叉验证的每一折内部用训练折的中位数去回填验证折。你可能会觉得这样麻烦但对于回归任务任何用全量训练集统计量去填充验证集或测试集的做法都存在把目标信息泄漏给特征的风险。3. 特征工程把车况变成模型能吃下的数字特征工程是这个项目拉开差距的主战场。同一个模型特征做不做、怎么做线上分数能差出好几个百分点。3.1 数值特征改造车龄、里程与功率的正确姿势对一辆二手车来说车龄和里程是定价的两个主轴。原始 regDate 是一个整数不能直接喂给模型要先转成车龄。数据交易时间大致在 2017 年前后我会用数据里出现的最大注册年份来近似交易年份。同时把里程除以车龄得到年均里程这个特征能暴露出一些里程很低但车龄也很低的车以及车龄短但里程异常高的车后者往往是调表嫌疑车。功率本身是偏态分布直接喂给树模型也能学但做一次 log 变换能让它的分布更接近正态方便树模型在低功率区间做更细的切分。# 车龄: 用数据中的最大年份近似交易年份 max_year int(max(train[reg_year].max(), test[reg_year].max())) for df in [train, test]: df[age] max_year - df[reg_year] # 年均里程, 1防止除零 df[km_per_year] df[kilometer] / (df[age] 1) # 功率取log df[power_log] np.log1p(df[power])这段代码的要点是 km_per_year 这个派生特征。原始 kilometer 和 age 已经在线性模型中够用但在树模型里两个特征分别切分和它们的比值切分表达的物理含义完全不同比值能直接反映车辆的磨损强度。我实际跑下来的经验是km_per_year 的加入对 OOF 分数有明显贡献值得保留。3.2 类别特征编码品牌、车型与车身类型brand、model、bodyType 这类字段在数据里本来就是整数编码树模型可以直接拿数字当切分点。但这里有个细节brand 的低频品牌加起来也有不少样本直接把它们当独立 ID树模型很容易在低频 ID 上过拟合。常见做法是把低频类别合并成一个其他类再用模型自带的类别处理去学。def merge_low_freq(series, keep_top30): counts series.value_counts() keep counts.head(keep_top).index return series.where(series.isin(keep), other-1) for df in [train, test]: df[brand_cls] merge_low_freq(df[brand], keep_top30) # 品牌和车型组合, 同一品牌下不同车型价格差异极大 df[brand_model] df[brand].astype(str) _ df[model].astype(str)有两个问题值得注意。第一合并阈值 keep_top 的取值要看样本量数据量十几万条时保留 30 个品牌是合理的如果你的数据量翻倍可以放宽到 50。第二brand_model 组合特征的类别数是 brand 和 model 的笛卡尔积树模型能利用这个组合 ID 直接切出某品牌某车型的高价区间来这比单独两个特征反复交叉切分要高效得多。代价是类别数多了不少需要配合足够的叶子节点。3.3 组合特征与匿名特征哪些是真信号哪些是玄学name 字段是明文车型名信息量很大但直接编码不现实。常见做法是从里提取品牌和车系关键词或者干脆只用它做频次统计。这个字段里名字越长的车往往配置描述越全价格也偏高所以 name 的字符串长度和词数本身就可以当特征。匿名特征 v_0 到 v_14 是最值得花时间的地方。它们没有业务含义但和价格的相关性经常比显式特征还高。我的建议是先把它们和 price_log 的相关性排序挑出相关性高的进模型至于匿名特征之间的加减乘除属于典型的玄学调参我一般放到 baseline 稳定之后再去试。# 匿名特征与目标的相关性排序 v_cols [fv_{i} for i in range(15)] corr train[v_cols [price_log]].corr()[price_log].sort_values() print(corr) # 简单做两个相关性较高的匿名特征交叉 train[v_ratio] train[v_4] / (train[v_7] 1e-6) test[v_ratio] test[v_4] / (test[v_7] 1e-6)注意这里加了一个 1e-6 的极小值防止分母为零这是匿名特征做比值的通用防除零手段。加完 v_ratio 后一定要做 A/B 验证看 OOF 分数是否真的下降因为特征之间高度相关时新增特征可能只是给树增加了噪音切分的空间分数不升反降。4. 模型选型与调参能直接跑出分的 LightGBM 配置特征工程做完模型选择反而是省心的一步。这不是说模型不重要而是说在表格数据里树模型的优势太明显不需要纠结。4.1 为什么选树模型而不是线性模型线性模型需要手动构造大量交互项和多项式特征才能捕捉车龄、里程、品牌之间的非线性关系。而树模型天然按特征值做分段切分每一段可以学到一个局部均值非线性关系和特征交互都是自动完成的。另一个实际原因是树模型对缺失值和异常值容忍度高LightGBM 原生支持 NaN 作为切分方向不用像线性模型那样必须先做填充和标准化。对比维度可以简单归纳成一张表维度线性模型树模型特征缩放必须不需要缺失值必须填充原生支持交互项手动构造自动切分类别特征编码复杂整数直接切实际项目里我会在 LightGBM 和 XGBoost 之间选原因是训练速度和调参成本。LightGBM 在大样本下明显更快而且对类别特征的处理更省内存。4.2 LightGBM 参数实战一份能直接跑的脚本我给出目前最常用的一套配置配合 5 折交叉验证跑出来的 OOF 分数可以直接作为后续所有迭代的基准线。import lightgbm as lgb import numpy as np from sklearn.model_selection import KFold from sklearn.metrics import mean_squared_error params { objective: regression, metric: mse, learning_rate: 0.05, num_leaves: 63, max_depth: -1, min_child_samples: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l1: 0.1, lambda_l2: 1.0, verbosity: -1, seed: 42, } kf KFold(n_splits5, shuffleTrue, random_state42) oof np.zeros(len(train)) test_pred np.zeros(len(test)) for fold, (tr_idx, va_idx) in enumerate(kf.split(train)): dtr lgb.Dataset(train.iloc[tr_idx][features], train.iloc[tr_idx][price_log]) dva lgb.Dataset(train.iloc[va_idx][features], train.iloc[va_idx][price_log]) model lgb.train( params, dtr, num_boost_round10000, valid_sets[dva], callbacks[lgb.early_stopping(100), lgb.log_evaluation(500)] ) oof[va_idx] model.predict(train.iloc[va_idx][features]) test_pred model.predict(test[features]) / 5 rmse np.sqrt(mean_squared_error(train[price_log], oof)) print(OOF RMSE:, rmse)这段脚本的逻辑是每一折训练一个模型用验证折做早停决定树的数量再用这个模型预测验证折和测试集最后把 5 折的测试集预测平均。这样得到的是既不过拟合又覆盖全训练集的 out-of-fold 预测。参数方面learning_rate 0.05 配合 10000 轮和早停是自动决定棵数的稳妥组合。num_leaves 63 是 LightGBM 默认的大叶子数十几万条数据量下够用如果数据量翻倍可以尝试 127。feature_fraction 和 bagging_fraction 都设为 0.8 是为了增加列和行的随机性降低方差。lambda_l1 和 lambda_l2 是正则项前者对付稀疏特征后者控制整体复杂度。4.3 交叉验证与早停让验证分数可信交叉验证是这套流程里最值得抠细节的地方。如果直接随机 KFold每一折验证集里高价车的比例会有波动MSE 在贵价车上被放大分数忽高忽低你没法判断一个特征改动到底是真有效还是随机波动。我的做法是先对价格分箱再用分箱结果做分层 KFold让每一折训练集和验证集的价格分布保持一致。from sklearn.model_selection import StratifiedKFold # 价格分10箱, 用分层采样保证每折价格分布一致 train[price_bin] pd.qcut(train[price], q10, labelsFalse) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for fold, (tr_idx, va_idx) in enumerate(skf.split(train, train[price_bin])): # 和上面的训练逻辑一致, 这里省略重复代码 pass分层 KFold 带来的直接好处是 OOF RMSE 的波动明显收窄特征迭代时 0.001 级别的分数变化开始变得可信。另一个细节是 random_state 一定要固定否则每次迭代的折划分都不同你根本分不清分数变化来自改动还是运气。这套工程习惯养成后任何回归比赛都能直接复用。5. 避坑指南二手车价格预测的 5 个典型翻车点下面这几个坑几乎每个跑这个项目的人都会踩我把现象、原因和解决办法一次说清。5.1 线上分数比本地验证低一大截现象是本地 OOF RMSE 很好看提交后线上分数差一大截排名掉得莫名其妙。原因是训练集和测试集的分布本身存在差异随机划分的验证集无法代表真实测试场景更进一步的原因是某些特征用了全量训练集的统计量去填充验证集或测试集把未来的分布信息泄漏进了验证过程。解决方法是改用分层 KFold并在交叉验证内部完成所有填充和编码操作。比如用中位数填充 power每一折只能用训练折的中位数去填验证折不能用全集。这个习惯养成了线上线下分数的差距才会收窄到合理范围。5.2 同一个特征在训练集和测试集上分布不一致现象是某个字段在训练集里几乎全是同一个值测试集里却是另一个值模型等于拿着一把尺子在量另一套标准。seller 和 offerType 就是典型。原因是数据采集时的客观差异不是建模问题。解决方法是直接删掉这类特征判断标准就一条字段类别数少于 3 且某一类占比超过 99%直接 drop。这类字段不仅没有信息还会让树模型花精力去学一个在测试集里根本不存在的切分点造成线上预测偏移。5.3 模型总是低估高价车的价格现象是预测结果最大不超过 30 万但测试集里明显有更贵的车。原因是 MSE 目标下模型为了让整体误差最小会把高价样本的预测往中位数方向收缩因为高价车的误差权重太大。log1p 已经在缓解这个问题但相对误差在高价区间仍然让模型偏向保守。解决方法是不要试图强行修高价段比如单独训练高价子模型或者对高价样本加权这些做法实测收益很小且容易过拟合。更稳妥的是用 log1p 变换加合理验证提交前把预测结果 clip 到非负即可。5.4 加了高级特征后分数反而下降现象是加了 name 关键词计数、匿名特征比值之类的花活特征后OOF RMSE 不降反升。原因是这些特征和已有特征高度相关没有提供新信息还给树模型增加了无意义的切分空间有些特征在测试集上大量缺失最后退化成一个缺失指示器。解决方法是只保留能让 OOF 分数下降的特征新特征逐个加入每次只变一个变量用固定的同一套折去对比。如果 OOF 分数没有改善果断回滚。特征工程的原则是宁缺毋滥。5.5 提交结果格式错误直接零分现象是提交后系统提示行数不符或格式错误第一次跑比赛基本必见。原因多半是预测结果里多了索引列、或者行数比测试集多了几行空数据。解决办法是提交时只保留 SaleID 和 price 两列并在写出文件前手动校验行数。sub pd.DataFrame({ SaleID: test[SaleID], price: np.clip(np.expm1(test_pred), 0, None) }) assert len(sub) len(test), 行数不一致, 检查预测结果 sub.to_csv(submission.csv, indexFalse)这里 np.expm1 是把之前对价格取的 log1p 还原成真实价格np.clip 保证预测结果不为负数。提交前跑一次断言是最低成本的后悔药。6. 冲高分的关键一招把单模型换成融合单模型跑到稳定分数后想继续往上走融合是性价比最高的手段。6.1 验证习惯不好融合就是浪费时间先说我现在的固定流程任何新改动都先跑一次固定折的 baseline确认 OOF 分数没有明显波动再谈下一步。很多人在特征没稳定、验证方式不统一的时候就开始做融合结果两个模型各有各的偏差融合出来反而更差。模型融合能赚钱的前提是各模型学到的错误模式不同如果两个模型高度同质平均只是在压缩方差不会带来真正的提升。6.2 模型平均与 Stacking可以照抄的融合框架最简单的融合是加权平均。当 LightGBM 和 XGBoost 的 OOF 分数很接近时直接取 0.5 比 0.5 的平均往往就能让线上分数小幅下降。XGBoost 的参数对照 LightGBM 设置eta 0.05、max_depth 6、subsample 0.8、colsample_bytree 0.8同样在 5 折交叉验证中做早停。如果想更进一步可以用 Stacking把两个模型的 OOF 预测当成第二层特征用一个线性模型去学它们的最优组合权重。from sklearn.linear_model import Ridge # lgb_oof 和 xgb_oof 是上面两个模型各自的交叉验证预测 # lgb_test_pred 和 xgb_test_pred 是它们对测试集的平均值 meta_X np.column_stack([lgb_oof, xgb_oof]) meta_model Ridge(alpha1.0) meta_model.fit(meta_X, train[price_log]) meta_test np.column_stack([lgb_test_pred, xgb_test_pred]) final_pred meta_model.predict(meta_test)这里的核心是第二层只用岭回归千万不要上复杂模型。第二层样本量只有训练集大小特征只有两三个用复杂模型几乎必过拟合。alpha 可以适当调大1.0 起步效果不明显再试 5.0。跑这类天池二手车价格预测我最深的感受是高分不是某一个模型给的而是数据理解、特征工程、验证方式和融合策略叠加出来的。我现在的习惯是每次迭代都先跑一个固定折的 baseline任何新特征、新参数先在同一折上做一次 A/B看到分数真的动了才保留。这套流程跑熟了换到任何一张表格型数据集都能快速上手希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网