ARTICLE DETAIL

资讯详情

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

Kaggle二手车价格预测实战:数据清洗、特征工程与LightGBM融合

Kaggle二手车价格预测实战:数据清洗、特征工程与LightGBM融合 最近在Kaggle上刷二手车价格预测的回归题陆陆续续折腾了将近两周。这个项目很适合作为机器学习入门练手任务目标清晰、数据量适中、特征里既有数值又有文本能让你把数据清洗、特征工程、模型调参和集成学习全流程过一遍。无论你是刚接触Kaggle还是想巩固回归模型这篇实战笔记应该能帮你少走点弯路。很多人觉得回归问题简单无脑跑个LightGBM就能拿到还行分数但实际做下来你会发现真正拉开差距的往往不是模型而是你前期的数据处理和对业务逻辑的理解。二手车价格本身受品牌、车龄、里程、车况等多个因素影响这些特征之间互相纠缠还有大量缺失和不规范文本处理起来非常考验功夫。这篇文章不会教你背代码而是带你一步步理解每个环节的“为什么”然后给出可以直接复用的方案。1. 项目背景与数据理解1.1 赛题目标与评分机制这个项目本质上是典型的监督学习回归问题给定一辆车的历史特征预测它的成交价格。官方提供的训练集里包含几十万条车辆记录每条记录有品牌、型号、年份、里程、排量、变速箱、车况、燃料类型等字段目标列就是price。测试集没有价格你要用训练好的模型去预测测试集每个样本的价格然后提交预测结果。评分指标用的是RMSLERoot Mean Squared Logarithmic Error。很多新手第一次见这个指标会疑惑为什么不用RMSE或者MAE因为二手车价格分布极度偏斜少数豪车价格可能到几十万甚至上百万而普通家用车只有几千到几万。如果直接用RMSE模型会把大量注意力放在那些极端高价的样本上导致对中低价车的预测飘忽不定。RMSLE先对价格取对数再计算误差本质上是把绝对误差转换成了相对误差。同样误差幅度对高价车和低价车的惩罚是近似的这样更贴近实际业务中“价格越低预测误差容忍度越小”的需求。理解评分指标很重要它直接决定了你的数据预处理策略。既然要用对数误差那目标变量就天然适合做log1p转换即对 price 加1取自然对数让目标分布更接近正态训练时模型收敛也会更稳。后面所有模型的预测值还要记得用expm1还原成真实价格再提交。实际操作时建议自己先划分一个验证集按训练集和测试集同分布的原则随机切分个10%出来后续所有特征工程和调参都在这个验证集上评估避免反复提交测试集造成过拟合。我这轮项目就是靠这个验证集避免了在排行榜上“大起大落”。1.2 数据字段解读与初步排查拿到数据后先别急着建模我会习惯性地用pandas做一轮快速扫描shape、dtypes、describe()和每列的缺失比例。这个项目的数据集包含了类似id, year, manufacturer, model, condition, cylinders, fuel, odometer, title_status, transmission, VIN, drive, size, type, paint_color这些字段。其中数值型不多主要就是year和odometer其余全是类别文本。year字段看起来是整数但你可能发现有些值小得离谱比如 1990 年以前的“老古董”或者未来年份的异常值数据爬取时输入错误。odometer是里程数同样可能有0值或者超大的极端值。类别字段的问题更多manufacturer里会混入拼写错误或别名比如 “ford” 写成 “f0rd”model的基数极大可能有数千种不同名称而且有些 model 的命名不规范大小写、后缀数字都不同。还有一个容易忽略的点VIN车辆识别码虽然是文本但里面藏着信息。VIN的第1位代表生产国家/地区第2位代表制造商第3位代表车辆类型或制造厂。通过VIN的前3位可以反向验证manufacturer字段是否正确甚至在某些行缺失manufacturer时能从VIN中推断出来。这个思路对补齐缺失很有帮助。我建议把数据类型也做一次统一检查避免后续的 One-Hot 编码或数值转换时爆出莫名其妙的问题。比如cylinders字段看起来是数字但可能包含 “6 cylinders” 这样的文本必须先清洗成统一格式。整个过程就一句话先把数据摸透再谈建模。2. 数据清洗与可视化分析2.1 缺失值与异常值处理这个数据集的缺失值主要体现在manufacturer、model、year、odometer、condition、cylinders和int_col等字段上。其中model缺失比例不高但影响很大因为同一款车的价格区间非常集中缺了 model 等于丢失了强特征。我的处理策略分成三层。第一层能通过其他字段推断的尽量推断。比如缺失manufacturer或model的行只要VIN完整借助已有的make_model映射表或者公共VIN解码库补全。没有外部工具时简单的做法是看其他同year、同condition、同size、同type且manufacturer相同的样本用众数填充缺失的model。我用这个方法补全了大概70%的缺失行。第二层缺失占比高且与目标变量关系弱的字段直接丢弃。比如某些颜色字段缺失了30%对价格预测的增益本身就小强行填充反而会引入噪声。这里要注意一个原则删除缺失值严重的行为可能丢失大量信息如果是关键特征缺失尽量保留如果是不重要的辅助字段删掉也不心疼。第三层对odometer这类数值特征不要无脑填均值。因为里程和价格是强负相关用整体均值填充会严重削弱特征区分度。更好的做法是按year和type分组取中位数填充实在没有分组信息就填中位数。填充后我还把所有odometer0的样本单独标记出来因为这些大概率是数据录入错误或者非正常交易记录单独用0值填充会让模型误以为“零里程新车”所以统一按缺失值处理会好很多。异常值方面画出odometer和price的散点图就能看到有些odometer超过几百万英里这明显是错误数据直接删除或缩尾到99分位数。千万别在预处理阶段就删掉所有高价车低价车的极端值也一样重要删除时要有理由最好记录下来以便复盘。2.2 目标变量分布与特征相关性先用seaborn.histplot画出price的分布你会发现它严重右偏大量车集中在5000到15000美元区间右侧尾巴拖得很长。对log1p(price)再画一次分布变得非常接近正态峰态也健康多了。这一步做实了前面转换的必要性。然后做数值特征之间的相关性矩阵。year和price呈强正相关新车价格高odometer和price呈强负相关里程越高价格越低。但year和odometer之间也相关因为年份越老里程一般越高。树模型不太怕共线性但线性模型就容易被扰动这也是为什么我即使跑线性模型也会考虑做Z-score标准化。还要把类别特征和价格的均值关系可视化一下。按manufacturer分组看平均价格你会发现豪华品牌和非豪华品牌的差距非常大。按transmission分组看平均价格自动挡的车普遍比手动挡贵。这些观察能指导后面构造新特征比如“品牌平均价格”就是直接从这些分组统计里得到的。注意分箱看year和price的关系时不要只看整体线性趋势有些年代的车因为稀缺性会出现异动比如某些经典款老车价格反而高。如果直接拿year做线性拟合这些非线性关系会被忽略所以给模型提供更丰富的交互特征或直接用树模型捕捉这些模式是更稳妥的做法。3. 特征工程与预处理3.1 类别特征编码方案类别特征里低基数的比如fuel汽油、柴油、混动等、transmission、drive可以直接用 One-Hot 编码。但manufacturer有几十个model有几千个直接 One-Hot 会产生巨大的稀疏矩阵不仅内存爆炸而且容易过拟合。对这类高基数类别特征我强烈推荐两层策略。第一层对基数小于50的类别用 One-Hot 或 frequency encoding。frequency encoding就是用该类别在训练集中出现的频次替换原始值比如ford出现10000次就填10000。这个编码方式是线性的保留了类别的流行程度信息对线性模型和树模型都有效。第二层对基数极大的model用 target encoding也就是用目标变量对数价格在当前类别下的均值来替换原始值。比如某个具体车型的平均log1p(price)是9.2这个车型的所有样本在model_encode列就填9.2。target encoding 有两个致命坑必须避开一是要在每折交叉验证中分别计算编码值否则会用到验证集信息造成数据泄漏二是在类别样本很少时均值波动极大需要做平滑处理。具体实现上我用这样的方法循环每折训练数据按类别计算target均值然后映射到验证数据。为了防止小样本类别波动使用加权平滑global_mean train[log_price].mean() count train.groupby(model)[log_price].transform(count) sum_by_model train.groupby(model)[log_price].transform(sum) smooth (sum_by_model global_mean * alpha) / (count alpha)alpha是一个平滑系数我通常取5或10。样本量越少的类别它的编码值会越接近全局均值这样既保留了信息又抵消了噪声。这个 trick 在我这个项目里让验证集RMSLE下降了0.02左右效果非常明显。剩下的title_status看似文本其实类别不多比如 clean, salvage, rebuilt, lien 之类直接 One-Hot 或者 label encode 都行我习惯用 frequency encoding因为它能保留“该状态出现频次越高越常见”的信号。3.2 数值特征变换与构造新特征数值特征不只有year和odometer。odometer本身极端值很多我直接做了分位截断1%到99%然后取log1p这样长尾被压缩线性模型更容易拟合。同时要额外保留一个odometer_0_flag标识原始值是否为0让模型自己学习0值这条规则。车龄特征非常关键car_age 当年年份 - year。这里的“当年年份”可以用数据发布时间如果比赛是2024年的就取2024但为避免测试集出现未来年份的车可以统一取数据中最大年份作为基准。车龄与价格基本是单调递减关系但非线性很严重比如前3年贬值最快。所以我又构造了car_age_squared和car_age_log给树模型更多搜索空间。还有一个非常实用的特征model_avg_price和brand_avg_price。这个和target encoding类似但粒度不同。品牌层面的平均价格比具体车型更容易稳定能捕捉“这个品牌的整体定位”。例如宝马整体价格高于起亚。模型层面的平均价格更进一步把同款不同年份、不同里程的车锚定在一个基准价格上对预测帮助极大。交互特征我也做了一部分比如year_odometer_ratio年平均驾驶里程等于odometer / max(year,1)。这个特征可以反映车辆的使用强度相同年费的老车如果里程很低说明可能是收藏车或备车价格会偏高。注意当year为0或极小时该值会疯狂所以先处理异常年份再计算。文本特征方面VIN可以提取首位字符作为国家地区编码或者提取第4到第8位的车辆特征代码但由于数据噪音太大我只保留了首位并映射成类别编码。另外我还从name字段如果比赛输入有标题中提取了一些关键词比如“sport”、“limited”、“leather”等用简单的contains方式生成布尔特征。这些关键词是二手车广告中的常见卖点能直接反映配置差异。不过做这一步前一定要检查关键词在训练集和测试集中的出现频率防止测试集出现训练集没有的生僻词导致特征全为0。所有类别和数值特征最终拼到一起我会统一用pd.get_dummies处理低基数分类变量然后保存为一个清理后的数据集。切记特征工程要在交叉验证开始之前全部做完但 target encoding 相关特征必须在折内重新计算不能预先用全量训练集编码否则验证集分数会被严重高估。4. 模型构建与调参实战4.1 基线模型线性回归与Lasso我习惯先跑一个简单模型建立基准分数而不是一上来就调LightGBM。这个项目的基线我用的是Ridge和Lasso。先把所有数值特征标准化StandardScaler类别编码后的特征也都标准化然后做5折交叉验证。线性回归在这个项目上肯定不是最优解但它能帮你快速估算出特征的线性组合和实际价格的偏差到底有多大。我跑出来的基线RMSLE大约是0.42对数尺度这已经能说明问题了线性模型对非线性关系捕捉能力不足。Lasso会让部分不重要的特征系数变成0我用它做过一次特征筛选发现model_avg_price、odometer_log、car_age这几个特征的系数最大这坚定了我对这些特征重要性的判断。不过线性回归最大的问题是它假设特征与目标之间是线性关系而价格和里程、年份的关系明显不是线性的。所以线性模型的分数注定不会太高但它作为提交保底方案是够用的。后面所有模型的目标就是在这个基线上往下压RMSLE每次下降0.01都说明特征工程或模型选择方向是对的。这里要提醒一点使用Ridge/Lasso之前目标变量的log变换必不可少否则残差分布极端交叉验证分数会忽高忽低。我还在验证集上额外计算了未取对数时的RMSE发现线性模型的RMSE被少数豪车拉得极大这再次验证了对数指标的必要性。4.2 树模型XGBoost与LightGBM的调参思路树模型是这个项目的主力。我分别试验了XGBoost和LightGBM两者都能轻松跑到RMSLE 0.28左右但LightGBM训练速度明显更快所以后续迭代主要围绕LightGBM展开。XGBoost我作为融合成员保留。LightGBM的核心优势是它原生支持类别特征不需要手动One-Hot。我在代码里直接用categorical_feature指定那些编码后的类别列让模型自动处理高基数类别避免稀疏矩阵。基础参数初始化我按照经验设置params { objective: regression, metric: rmse, learning_rate: 0.05, num_leaves: 63, max_depth: -1, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l1: 0.1, lambda_l2: 1.0, verbosity: -1, }为什么learning_rate要设低一点低学习率配合多迭代轮数能让模型慢慢收敛避免前期步子太大直接跳过最优解。我用早停法early stopping自动确定最佳迭代次数训练时会留出10%数据作为验证当验证集RMSLE连续50轮不下降就停止。这种方式比手写固定轮数稳定得多也能防止过拟合。num_leaves是LightGBM里最重要的参数之一它控制树模型的复杂度。过大会让模型记住噪声过小会欠拟合。我从31到127逐步网格搜索发现63在验证集上表现最好。feature_fraction和bagging_fraction是随机采样参数能让每棵树看到不同的特征和样本增强鲁棒性。训练时还要注意类别特征列要用字符串或整数编码并且编码后需要是categorydtypeLightGBM才能识别。调参思路不是一股脑用Optuna跑几百轮而是分阶段。第一阶段固定其他参数先调learning_rate确定合适的迭代区间。第二阶段调num_leaves和max_depth。第三阶段调采样比例和正则化参数。每轮都用同样的5折交叉验证记录每一折的RMSLE并关注标准差如果折间标准差很大说明模型不稳定可能需要增加正则化或者剪枝。我还尝试了XGBoost的hist树构造方法它和LightGBM类似速度也很快。XGBoost与LightGBM最大的差异在于正则化策略和分裂点的逼近方式在中等数据规模上两者分数非常接近。但XGBoost对内存的占用更高对类别特征的编码要求更严格所以我都是手动做了target encoding后喂给XGBoost而不是直接传类别列。最终单个LightGBM模型在验证集上的RMSLE大约是0.247XGBoost单模型是0.249。这个成绩已经比线性基线好了一大截。5. 结果评估与经验总结5.1 模型融合与最终提交单个模型成绩接近时融合能再压一压误差。我尝试了两种融合方式简单加权平均和Stacking堆叠。加权平均最简单预测值分别为pred_lgb和pred_xgb最后输出0.5 * pred_lgb 0.5 * pred_xgb。我在验证集上测试了不同权重发现LightGBM权重0.6、XGBoost权重0.4时RMSLE最低到0.241。融合之所以有效是因为两个模型的偏差结构不完全相同一个更擅长捕捉局部特征另一个更擅长全局趋势加权平均可以把各自犯的错误对冲掉。Stacking稍微复杂一点用基础模型的验证集预测结果作为新的特征训练一个二级模型我用了Ridge最后用二级模型的预测结果提交。这个方法比简单加权效果好那么一点点RMSLE可以到0.239但也不是每次都能稳定提升。如果基础模型之间的相关性太高stacking收益就有限。我这次实验里LightGBM和XGBoost的相关性很高所以提升幅度并不大。如果你是上手练习先用简单加权平均就好stacking可以作为进阶玩法。提交前必须把预测值通过expm1还原成原始价格然后写入CSV列名和测试集id对应。记得检查是否有预测出负数的情况对数预测还原后理论上不会小于0但极端情况下也可能出现极小值如果小于500就直接截断到500避免太离谱的预测。5.2 踩坑记录与实用技巧整个项目做下来我踩了不少坑这里挑几个最典型的说说。第一个坑是target encoding泄漏。我第一次做target encoding时直接在全量训练集上用groupby.transform(mean)编码然后切出验证集结果验证集RMSLE低到0.18让我高兴了半天。但提交到公共排行榜却只有0.29瞬间打回原形。后来才明白验证集已经参与过编码计算信息泄漏了。正确做法是必须把编码计算放在交叉验证的每一折内部或者使用专门的库如category_encoders.TargetEncoder它会自动处理CV内部的编码。从那以后我所有涉及目标统计的特征都严格在CV内部生成榜单和离线分数终于对上了。第二个坑是模型对model缺失值的敏感。之前偷懒直接填充“missing”字符串结果这个类别的样本被target encoding赋予了一个偏高或偏低的值模型乱猜。后来我特意为正则缺失的model单独建了个特征并用品牌均值作为baseline让模型知道这些样本信息不足最后分数又降了一点。第三个坑是过早删除异常值。一开始我把价格超过15万、里程超过50万英里的样本全过滤掉了结果测试集里还有这种极端样本模型只能瞎猜。后来我改成不删除而是做缩尾clip到99分位数把异常样本的量级压到合理范围模型对这种分布外的泛化能力反而更强了。第四个坑是关于数据版本的。我迭代了很多版特征工程如果不做记录过两三天就忘了哪个脚本产出哪个特征。后面我每次改动都用git管理代码并且把生成的特征矩阵保存为parquet文件文件名带上版本号和时间戳。这样一旦发现当前版本分数倒退可以快速回退到上一版本对比省下的时间不可估量。最后再说一个很实用的技巧定期查看模型的特征重要性。LightGBM的feature_importance(gain)能告诉你哪些特征对分裂贡献最大。我跑完一轮后把重要性画出来发现model_avg_price和odometer_log断层领先这说明用户买二手车时对具体车型的定价锚点非常敏感。如果看到某个构造特征排在末尾考虑能不能把它替换成更有区分度的变体而不是急着增加更多新特征。我个人的一点体会如果你只是想随便跑个分数按这篇文章的流程走一遍就能拿到不错的成绩。但如果你想真正提升建模能力建议多在特征工程的细节上抠一抠比如尝试给高基数类别做层次编码或者结合汽车行业知识构造“折旧率”一类的业务特征。暴力调参从来不是Kaggle的核心乐趣把每个环节的“为什么”想明白才是从这个项目里带走的最大价值。我在实际做的时候最后悔的是前期没把缺失值当回事以为树模型能自动处理缺失就放着不管结果特征工程质量大打折扣。建议每一步都做好版本记录数据清洗和特征生成尽量封装成函数后面迭代会舒服很多。希望这篇笔记能帮你少踩几个坑。
返回列表