
第一次在Kaggle上看到“Regression of Used Car Prices”这个竞赛时我的第一反应是这不就是个普通的二手车价格预测吗套路早就被玩明白了。可真把这个项目从头到尾做完一趟我才意识到自己之前把很多事情想得太简单——数据里的脏噪、缺失、长尾分布、类别编码的坑、模型验证与线上分数的差异每一项都能让一个看起来“跑得通”的方案翻车。这篇就基于我实际做过的版本把这个车价回归项目的完整思路、关键操作、踩过的坑一次性整理出来。不管你是刚入门的Kaggle新手还是想补强表格数据建模细节的选手这篇都值得你泡杯茶慢慢看。1. 项目整体设计与思路拆解1.1 别急着建模先想清楚这是哪种回归二手车价格预测是典型的表格数据回归问题。拿到题目后大多数人的本能是赶紧翻公开notebook找现成代码但我建议先把节奏放慢。回归任务和分类任务本质上不同分类只需要把样本分到正确的桶里而回归要求数值尽量贴近真实目标任何一个极端样本都可能把整体误差拉得很高车价数据恰好就是这种“少数高价车、大量普通车”的形态。这个竞赛的任务说起来很直观根据车辆的品牌、型号、注册年份、里程数、燃料类型、变速箱、颜色、事故记录、保养记录等信息预测一辆二手车的最终成交价格。但这批特征之间不是互相独立的年份和里程共同决定折旧程度事故和保养记录又直接影响残值所以这不是“把字段扔进模型就行”的题而是需要先理解数据背后的业务逻辑。我在动手前给自己列了三个必须回答的问题目标变量长什么样是否偏态、有没有极端值竞赛用什么指标算分这会直接决定建模路线的选择特征是否覆盖了业务里影响车价的几个维度这三个问题不解决后面调模型都像是在盲打。记得我第一次闷头跑基线完全跳过了EDA结果发现线上分数和本地验证差了近4个百分点问题就出在我根本没意识到价格分布有多偏。1.2 评估指标决定建模路线不是随便选个Loss回归任务的指标选择不是小事常见的RMSE、MAE、RMSLE对同一组预测结果的评价角度完全不同。二手车价格这类竞赛通常采用的是RMSE它对大误差样本惩罚非常重意味着模型不能只是在“大体上猜对”而是要把那些价格明显偏离的极端样本也压住。这就引出了一个关键决策要不要对目标变量做变换。我先讲结论——我当时对价格做了log1p变换也就是把目标从price变成log(price1)。原因在于RMSE是平方误差价格跨度从几千到几万美元直接回归时模型的损失会被那些高价车主导低价车的相对误差反而被忽略。而取对数之后价格尺度被压缩每个样本在误差里的权重更均衡模型会更“公平”地学习所有区间。代码上非常简单import numpy as np # 训练前 train_y_log np.log1p(train[price]) # 预测后还原 pred_price np.expm1(pred_log)还有一个容易被忽略的点如果竞赛指标是MAE那取对数就不一定划算因为MAE对尺度本身不敏感反而更关注中位数附近的拟合质量。所以每换一个竞赛先花五分钟理解指标这比调任何超参数都值钱。1.3 定一个“先跑通再优化”的节奏我个人的习惯是不管思路多完整第一版一定先做一个最简陋的基线只用原始数值特征和简单标签编码跑一个LightGBM默认参数看看分数大概在什么位置。这个过程的意义不是追求成绩而是建立一条“分数参考线”。有了基线之后后续每一次特征工程和模型改动都能用这条线来评估到底有没有正向收益。很多新人容易犯的错是一上来就做十几组特征、上各种模型融合结果连最基本的分数底线在哪里都不知道出了任何问题也没法定位。在二手车价格这个项目上我的节奏是先基线、再清洗、再特征、最后才谈调参和集成一步一个脚印反而比一开始就“火力全开”走得快。2. 数据画像与关键预处理2.1 拿到数据先做“人口普查”我跑的那版数据集里的字段大致包括id、brand、model、model_year、milage、fuel_type、engine、transmission、ext_col、int_col、accident、cleaned_title目标字段是price。我第一次看数据时没有直接改东西先做了一轮“人口普查”也就是把字段类型、缺失率、唯一值数量都摸清楚。import pandas as pd train pd.read_csv(/kaggle/input/used-car-price/train.csv) test pd.read_csv(/kaggle/input/used-car-price/test.csv) print(train shape:, train.shape) print(test shape:, test.shape) print(train.dtypes) print(train.isnull().mean().sort_values(ascendingFalse))这轮下来能发现几个典型问题部分字段缺失率超过20%比如cleaned_title、accident有些看似数值的字段其实是对象类型比如engine里可能混了单位信息还有一些字段有多余的占位符值比如ext_col里出现“—”、“0”这类噪声这些细节如果不在开始就处理后面过LightGBM时会直接报错或者被当成高基数类别模型效果非常差。我当时见过一个选手直接跳过清洗把一两万个唯一值的model列做LabelEncoder丢进模型训练分数看起来很漂亮验证分数惨不忍睹——这就是数据预处理偷懒的代价。2.2 缺失值处理原则保留缺失信号缺失值处理是表格比赛里最容易写出差异化的一步。我的原则很简单缺失本身就是一种信息不要简单地“填掉就完事”。具体到二手车数据我当时使用了这样的策略字段缺失情况处理办法cleaned_title显著缺失缺失统一填“Unknown”作为独立类别保留accident少数缺失填“none”同时保留字段本身的业务含义fuel_type极少数缺失用众数或最常见的类别填充engine极少数缺失填“Unknown”并后续拆解特征对于分类字段我倾向于用“Unknown”这种字符串填充而不是直接填训练集里出现最多的类别因为树模型需要识别“这个样本缺失了这个信息”本身。对于数值字段如果缺失率极低可以用中位数如果缺失率较高就要考虑是不是该把缺失单独变成一个0/1标志位。另外我要提醒一个细节缺失值填充的统计量只能从训练集算再应用到测试集千万不要用全量数据去算这点在竞赛里非常重要。2.3 目标变量分布与业务逻辑过滤二手车价格这个目标变量我展开分析过画直方图后会看到明显的右偏长尾少量豪车或准新车价格在四五万美元以上大量普通家用车集中在1万到3万美元。这就是前面说的为什么要做log变换。除了分布形态我还要做一轮业务逻辑层面的数据体检。比如milage字段有些车的里程为0这在真实世界里几乎不可能大概率是缺失值被写成了0这类极少数样本我会直接在训练集里删除再比如model_year如果出现明显超出合理范围的年份比如几十年后的年份也要用逻辑过滤掉。这里有个大原则所有基于“业务常识”的清洗只能作用在训练集测试集一条都不能动。我在项目里还发现过price与milage出现明显矛盾的情况比如50万公里里程的车价格反而极高。遇到这种异常先别急着删我会看看是不是其他特征能解释——比如这辆车是特殊收藏款或稀有改装。如果确实找不到解释并且数量很少删除或者让模型自行处理都是可接受的选择但绝对不要因为“看起来不对劲”就批量删数据那会影响模型对真实分布的把握。3. 特征工程操作指南3.1 类别特征编码别只会LabelEncoder二手车数据里有大量类别字段从品牌、型号、燃料类型到颜色一应俱全。不同字段的基数差异非常大brand可能只有几十个类而model可能有上千个类。我建议分场景选择编码方式。低基数类别比如fuel_type、transmission直接OneHot或者让树模型处理都可以高基数类别比如model可以用频次编码也就是用该型号在训练集里出现的次数作为特征因为稀有型号的数据往往更难预测频次特征能帮助模型识别这一点有序类别比如accident的取值里存在“发生过事故”和“没有事故”这种二元语义直接做二值化就好用LightGBM时要注意类别特征最好声明成category类型或者在参数里指定categorical_feature否则把它当成普通整数列模型会认为数值有大小关系产生错误的切分逻辑。这个坑特别隐蔽我见过不少人把品牌编码成0、1、2直接扔进去结果模型把“0号品牌”和“2号品牌”当成了有大小关系的数据训练时自己都没察觉。如果模型换成CatBoost那会省事很多它原生支持类别特征内置了有序目标编码你只需要把类别列的类型设为category传入即可。所以在做特征工程之前先确定自己主用哪个模型再决定编码方式是更高效的路线。3.2 构造时间相关特征车龄是核心变量车龄是车价最直接的杀手这一点所有做过二手车预测的人都会认同。我当时的做法是提取一个market_year或数据的平均年份然后计算车龄 当前年份 - model_year。如果只把model_year直接交给模型模型也能用但车龄的语义更清晰而且“当年份标签和真实交易时间存在偏移”时显式计算出车龄往往更稳。更进一步的实践是车龄非线性化。二手车折旧不是匀速的新车落地前三年贬值最快后面衰减变缓。所以我加了车龄的平方项还尝试过按车龄分桶创建分段特征给LightGBM的叶子节点更细的切分依据。实测下来车龄相关特征单独就能把本地验证的RMSE压下一截这说明对业务逻辑的理解在特征工程阶段是能直接变成分数的。时间特征还能继续组合。比如车龄和里程数一起用能识别出“年纪大但里程低”的准闲置车和“年纪轻但里程高”的营运车这两种车在价格上会有明显差异。组合特征不一定非要靠模型自己学人为地在特征工程阶段把它们做出来本质上是在降低模型的拟合难度。3.3 品牌均价与目标编码的安全用法品牌对车价的影响太明显了宝马和五菱宏光的残值曲线完全是两个世界。所以构造“品牌平均价格”这类统计特征几乎是必选操作。但这里有一个新手最容易翻车的点如果用全量训练集去算品牌均价再用这个特征去训练同一个模型会出现严重的数据泄露。原因很简单某个样本的品牌均价里包含了它自己的价格信息模型相当于“看到了答案”训练时交叉验证分数会虚高线上却完全兑现不了。安全的做法是在每一个交叉验证折内部只使用该折训练部分的统计量来计算特征然后用这个统计量去转换验证部分。我知道这个实现起来有点绕但它是必须绕的。如果你不想自己手写循环也可以用现成库里的目标编码器但一定要在交叉验证的框架里调用否则就等于白忙。除此之外品牌与型号的组合也是一个高价值特征。原始数据里brand和model分开但你仔细研究会发现真正决定价格的是“具体哪一款车”而不是孤立的品牌。我当时的做法是把brand和model直接拼成一个新列brand_model虽然这个列基数很高但用频次编码或模型内部分类处理仍然能明显提升效果。3.4 文本类特征的简单清洗engine、ext_col这类字段通常不是纯数值也不是纯文本需要先清洗再使用。比如engine里可能写的是“2.0L Turbo”、“V6 Engine”这种字符串直接变成类别特征基数太高又没有保存排量信息。我当时用正则表达式把排量提取出来作为数值特征比如匹配2.0、3.5这些数字效果立竿见影。import re def extract_engine_size(engine_str): if pd.isna(engine_str): return np.nan match re.search(r(\d\.?\d*)L, str(engine_str)) if match: return float(match.group(1)) return np.nan train[engine_size] train[engine].apply(extract_engine_size)颜色字段则需要考虑合并。ext_col和int_col里“白色”、“黑色”、“灰色”占了大多数各种花哨颜色数量很少单独建类别只会增加稀疏性。我的做法是把出现频率低于某个阈值的颜色归为“Other”再用色调类分组深色系、浅色系、彩色系进一步压缩基数。这种处理不一定是分数提升最大的一步但对于稳定模型很有帮助。4. 模型选型、交叉验证与集成4.1 线性模型不是用来拿分的是用来兜底的很多Kaggle攻略一上来就LightGBM导致很多新人连线性回归都不会在表格竞赛里使用。我的习惯恰恰相反第一版基线我通常会用岭回归跑一遍。虽然线性模型在二手车价格这种强非线性数据上分数惨淡但它有三点价值验证特征与目标是否存在线性关系给我一个“最差也不会低过这条线”的参照系如果后续特征工程有效线性模型的分数也会同步提升这能反向验证特征的质量我当时跑完岭回归RMSE在二万出头典型的偏高。但我不失望这个分数告诉我线性假设在这个问题上不成立必须上树模型。于是顺势切换到LightGBM默认参数一跑分数直接降到一万五模型选型的收益就是这么立竿见影。4.2 LightGBM是主力但不是唯一选项在我做这个项目时LightGBM毫无疑问是主力因为它的训练速度快对类别特征和缺失值都有内置处理内存占用也可控。但只用一棵树架在竞赛里等于放弃了很多别人会用到的机会。我当时至少训练了以下三种模型用于对比LightGBM主力模型速度快适合大量特征XGBoost与LightGBM互补正则更强在部分数据分布下更稳CatBoost原生类别特征处理出色对偏态数据也相对友好同一个特征集三种模型分别跑五折交叉验证然后对比本地CV分数你会发现它们的表现非常接近但细节上各有胜负。三个模型都训练还有个额外的好处集成之后通常能再压一点误差。不同类型的树模型在结构上有差异它们的预测误差不会完全重合取平均或加权平均后稳定性能提升。4.3 交叉验证用对策略才能信任分数交叉验证是整个建模流程里最不能跳过的环节。我对二手车价格这个项目采用的方案是普通的K-Fold五折shufflerandom_state固定一个基准值。原因是这个数据集本质上是一份横截面数据不是时间序列不需要硬上TimeSeriesSplit。但我强调一点交叉验证的目标不只是生成一个本地分数更重要的是判断模型稳定性。五折里每一折的RMSE如果标准差太大说明模型对数据分布很敏感这时候我会回去检查是否有异常样本或者特征泄露而不是急着调参。本地CV和线上分数出现系统性偏差大概率是验证策略出了问题不能靠运气去赌。LightGBM调参我走的是粗调路线先在固定learning_rate的情况下调节num_leaves、min_child_samples这类控制复杂度的参数等到CV稳定后再尝试降低learning_rate并增加n_estimators。一般不追求精细搜索因为比赛的边际收益有限花太多GPU时间去调那零点几个百分点的收益不如把精力放在特征和数据清洗上。import lightgbm as lgb from sklearn.model_selection import KFold from sklearn.metrics import mean_squared_error params { objective: regression, metric: rmse, learning_rate: 0.05, num_leaves: 63, min_child_samples: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, random_state: 42, n_jobs: -1, } kf KFold(n_splits5, shuffleTrue, random_state42) oof_pred np.zeros(len(train)) for fold, (tr_idx, va_idx) in enumerate(kf.split(X)): tr_X, va_X X.iloc[tr_idx], X.iloc[va_idx] tr_y, va_y y_log.iloc[tr_idx], y_log.iloc[va_idx] dtr lgb.Dataset(tr_X, labeltr_y) dva lgb.Dataset(va_X, labelva_y) model lgb.train(params, dtr, num_boost_round3000, valid_sets[dva], callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)]) oof_pred[va_idx] model.predict(va_X, num_iterationmodel.best_iteration) rmse mean_squared_error(y_log, oof_pred, squaredFalse) print(OOF RMSE:, rmse)4.4 集成别急着上先确认单模型做好模型集成是分数提升的常规手段但风险也在这里。很多人容易掉进疯狂堆模型的陷阱几个模型全上加权平均甚至再做Stacking结果本地分数提升了一点点上线反而翻车因为Stacking层如果构造不当会引入泄露。我的建议是先保证单模型足够好再考虑加权平均。Stacking可以玩但最好等单模型已经推进到瓶颈期再说而且Stacking的输入特征和训练方式都需要仔细设计否则就是自找麻烦。在二手车这个项目里三个模型按近似比例加权平均最终的收益大约是本地RMSE下降1%到2%不算巨大但那是建立在我已经把特征工程做到位的前提下。如果你特征还很粗糙先别指望集成来救分。5. 常见问题与排查技巧实录5.1 训练集分数和验证集分数差距大这个现象在新手里出现频率极高我自己也踩过。通常的原因有两类一是模型已经过拟合训练集里的噪声二是特征构造时无意引入了目标信息也就是泄露。排查思路是先看交叉验证每一折的分数差异如果折间标准差很大那就更可能是过拟合或泄露而不是随机波动。你可以先降模型的复杂度比如减小num_leaves、增大min_child_samples看验证分数是否回归正常。如果降复杂度和加正则都救不回来那就回头审查每一个特征它是不是全部用训练集算出来的统计量是不是包含了未来的信息二手车项目里品牌均价这种“统计目标特征”一旦用错就是典型的泄漏场景。5.2 提交格式出错白跑一趟这种情况其实不该发生但每年竞赛都有不少人中招。提交文件要求通常是两列第一列id第二列price。新手最容易犯的错有两个一是忘记对log变换后的预测结果做还原直接把log值提交上去结果RMSE爆表二是使用了没有经过Drop的测试集数量不一致导致行数对不上提交格式。我建议提交前做一个完整性检查确认四件事测试集的行数与sample_submission一致、id列顺序或至少列名一致、预测值已经用expm1还原、没有NaN值。跑一个简单的校验脚本十几秒的事能省下一次提交失败的机会。5.3 预测结果出现负数树模型在极端外推时也可能产出负的预测值这对价格预测来说是业务上的“不可能值”。我做这个项目时在验证集里看到过少量负值虽然占比极少但如果有评委或真实用户在看一个负数会显得很不专业。简单的处理是把最终预测clip到0或略高于0的下限pred_price np.expm1(pred_log) pred_price np.clip(pred_price, 500, None)这个clip的下限设计可以考虑业务场景比如不低于某个最小挂牌价而不是简单粗暴地贴到0。5.4 Kaggle平台与环境方面的新手门槛现在Kaggle的生态早就超出竞赛本身很多人把Notebook用来跑各种开源工作流但最基础的还是注册账号、创建Notebook、挂载数据集这几个步骤。新车手容易卡在数据集没有挂载成功就运行代码结果输入路径找不到文件。Kaggle Notebook里数据集路径是类似“/kaggle/input/数据集名/文件名.csv”的结构先去右侧数据集栏点“Add Data”再确认路径基本能避免大部分问题。另外跑这个项目其实完全不需要GPULightGBM在CPU上跑得非常快。很多新手上来自动开GPU等半天初始化队列完全没有必要。把Notebook的加速器设成None或CPU资源配额更稳定跑树模型也快。5.5 常见问题速查表问题可能原因处理建议本地CV好线上差特征泄露、提交格式错检查目标编码是否折内构造确认提交列和还原操作验证集分数波动大过拟合、异常样本干扰加强正则检查异常值和极端价格样本模型训练速度很慢类别特征未声明、数据太大声明category特征缩小无用特征调低n_estimators高基数类别效果差编码方式不当尝试频次编码或CatBoost原生态类别支持预测值不在合理区间极端外推对预测加业务合理的clip在我实际做完这个项目后最大的体会是Kaggle比赛拼的从来不是谁会用LightGBM而是谁在数据处理和特征构造上想得更完整。二手车价格预测这个题看起来质朴但每一环的取舍都有讲究认真走一遍你对回归问题的理解会上一个台阶。如果你现在正准备拿这个项目练手建议少跑几个模型多花点时间看数据和特征那才是收益最高的投入方向。