
从数据平台上下载那份二手车交易记录开始我就有种预感这个项目没那么简单。表面上看预测二手车成交价只是个回归任务可等你真打开数据文件看到几十个列名、一堆匿名特征和大量残缺字段时就会发现它其实是数据清洗、特征工程、回归建模到模型融合的全链路实战。这两年我拿这个题目练手过多次也带过几个新人完整复现可以说把它吃透基本就等于把表格类机器学习的主流程走了一遍。这篇文章就把我实际建模调参和模型融合的过程、代码、决策逻辑和踩过的坑一起整理出来给准备拿这个项目当面试素材或者入门实战的朋友一个参照。1. 赛题全貌与评估指标选择的底层逻辑先把这个二手车项目的底细摸清楚。赛题目标很直接根据车辆注册时间、行驶里程、品牌车型、变速箱类型、排量功率、是否受损等特征预测一辆二手车的成交价格。数据全部来自线上二手车交易平台的真实挂牌记录训练集大概有15万条样本测试集5万条字段将近40个其中还包括十几个匿名的统计特征列这对第一次接触的人来说会有点懵。1.1 数据集结构与字段体检拿到数据的第一步不是急着训练而是先做字段体检。我把典型字段大致分成了四类字段类别代表字段主要问题时间类regDate注册日期、creatDate上架日期需要转换出车龄/使用天数类别类brand、model、bodyType、fuelType、gearbox、regionCode基数差异极大brand有几十个类数值类power、kilometer、price含明显离群点量纲差异大文本/匿名类name、notRepairedDamage、v_0到v_14文本需要拆解匿名特征需探索分布这里最容易被忽略的是缺失值不等于没信息。比如notRepairedDamage这个字段缺失值占比很大但业务上它表示是否有未修复损伤缺失往往就是没有报修记录或平台没采集如果粗暴地填充成一个统一值等于抹掉了一部分信号。我当时的处理方式是保留缺失值本身作为一个独立类别让树模型自己去发现规律。1.2 为什么评估指标选MAE而不是RMSE这个项目标准评估指标是平均绝对误差MAE单位是万元。很多新手会问为什么不直接用RMSE原因是二手车价格分布严重右偏。我简单统计过训练集里价格的分位数25%的车在2万元以下中位数大概4万多但尾部有大量十几万甚至几十万的车最高能到100万以上。在这种长尾分布下RMSE会对那些极端高价值车辆的预测误差给出平方级惩罚导致模型为了压低少数豪车误差牺牲掉大多数普通家用车的预测精度。而MAE对每个样本的误差一视同仁更贴近业务里买家和卖家的直观感受。这一点直接影响了后续建模策略包括目标值变换。为了让误差分布更对称常规做法是使用log1p(price)做目标变换让原来的右偏分布变成近似正态。需要特别注意的是在计算最终MAE时要把预测结果指数还原后再计算误差而不是在log空间直接算MAE。1.3 数据切分与验证方式的设计赛题里给的是train和test两个文件但test是没有price标签的。所以我在train里自己切了一部分验证集出来做本地评估只在最后用全量数据重训模型提交预测结果。这里的核心经验是验证集划分策略会直接影响你后续调参的决策可信度。我对比过两种切法随机切和按时间切。这个数据集里creatDate跨度超过一年如果按时间前后来切会对模拟未来预测的场景更真实但考虑到车型更新、市场价格变动等外部因素严格按时间切会导致验证集分布与训练集有差异本地MAE会偏高。我的方案是先用随机切分来做特征工程迭代和调参对比最后提交前再用时间序列划分做一次交叉验证取两者之间的平衡。2. 特征工程从文本、时间到匿名列的三轮加工这个项目的特征工程空间非常大也是决定模型分数上限的关键。坦白说同样一份数据特征工程做到位的和随便跑跑LightGBM的MAE差距能有15%到20%。我把整个特征加工过程拆成三轮每一轮都有明确的动机和验证逻辑。2.1 时间特征与文本拆解把原始信息变成模型认识的世界第一步是处理时间列。原始数据里regDate格式是类似19990901的数字creatDate是20160301这样的新格式。我使用pd.to_datetime统一转成标准格式并且把两个时间列都先转换为字符串截取前4位作为年份再结合月份计算差值。import pandas as pd train[regDate] pd.to_datetime(train[regDate], format%Y%m%d, errorscoerce) train[creatDate] pd.to_datetime(train[creatDate], format%Y%m%d, errorscoerce) train[reg_year] train[regDate].dt.year train[creat_year] train[creatDate].dt.year train[used_time] (train[creatDate] - train[regDate]).dt.days / 365.0 # 有些上架日期早于注册日期说明是新车未上牌直接卖used_time会为负或接近0 train.loc[train[used_time] 0, used_time] 0used_time就是车龄这是影响二手车价格最核心的因素之一。我还额外构造了一个age_at_sale特征把上架年份减去注册年份作为整数车龄并保留一个月份级别的精确值用于捕捉半年以内的细微折旧差异。这里要指出一个业务细节德系豪华品牌和日系家用的折旧曲线差别极大所以后面做品牌分组编码时我也对车龄做了按品牌分组的均值编码而不是只用全局车龄。第二步是拆解name字段。name在原始数据里长这样宝马 3系 2013款 320Li 豪华设计套装实际上包含了品牌、车系、年份款、配置名称。我的做法是直接取空格分割后的前两个词作为品牌和车系train[brand_name] train[name].str.split( ).str[0] train[model_name] train[name].str.split( ).str[1]这个步骤别看简单收益非常明显因为品牌和车系直接决定了车辆的基础价格区间。后面我还对brand_name和model_name做了一次均值编码和频次编码频次编码是为了捕捉冷门车型可能带来的异常波动。2.2 类别特征与匿名特征的处理策略类别特征的处理是这个项目里最容易翻车的地方。brand字段有40多个类别model有200多个类别regionCode有30多个类别bodyType、fuelType、gearbox相对简单。立刻想到的one-hot编码对树模型来说并不是最优选择原因很简单类别太多会导致特征矩阵极度稀疏不仅拖慢训练速度对泛化也没帮助。我最终采用了两类编码组合频次编码groupby(brand)[brand].transform(count)给每个品牌一个全局出现次数。低频品牌往往代表冷门车型价格波动更大频次本身有信息量。目标编码但要带防泄漏对brand计算价格的均值编码时我用的是5折交叉验证内的均值而不是全量数据的均值。直接用全量均值会把训练集的标签信息泄漏到编码值里本地验证分数会虚高上了测试集就崩。from sklearn.model_selection import KFold def target_encode_with_cv(train_col, target, n_splits5, alpha10): global_mean target.mean() kf KFold(n_splitsn_splits, shuffleTrue, random_state42) encoded pd.Series(indextrain.index, dtypefloat) for tr_idx, va_idx in kf.split(train_col): grouped target.iloc[tr_idx].groupby(train_col.iloc[tr_idx]).agg([mean, count]) smoothed (grouped[mean] * grouped[count] global_mean * alpha) / (grouped[count] alpha) encoded.iloc[va_idx] train_col.iloc[va_idx].map(smoothed).fillna(global_mean) return encoded train[brand_target] target_encode_with_cv(train[brand], train[price_log], n_splits5)平滑系数alpha这里设成10能让样本量少的品牌向全局均值收缩避免小类过拟合。匿名特征v_0到v_14我做了更简单的处理先看分布其中有几个列分布接近标准正态但尾部有截断有几个列明显是类别型取值只有0/1或少量整数。对于类别型匿名特征我直接转成整数类别对于连续型匿名特征则做标准化后原样保留没有做PCA降维。原因是树模型对特征冗余不敏感而且它们和价格的关系未必是线性的PCA反而会破坏这种非线性信号。2.3 离群点与异常值清洗的边界在哪里在这个项目里离群点处理是争议最多的环节。power字段是用马力表示的正常家用车在60到300之间但数据里出现了power0和power12000这种明显异常值。我做了两步清洗power为0的记录用同一品牌下相似排量车型的马力中位数填充。power超过1000的记录先检查是否可能是跑车或者性能车确认是录入错误后直接删除。再就是价格异常样本。训练集里存在一些新车价格远低于市场价的记录多半是事故车或者水淹车这类样本用正常折旧模型是学不出来的。我根据used_time和品牌均价构造了一个价格残差把残差超过3倍标准差且在尾部极端的样本筛出来人工检查确认无业务合理性后删除。这里要注意一个度不要为了追求完美分布而删掉太多样本删到训练集失真就得不偿失了我最后只删除了不到训练集1%的记录效果却是本地MAE下降了3%左右。3. 建模与调参从LightGBM基线到网格寻优的完整路线特征工程做完就该进入建模阶段了。这里我要强调一个很多新手会犯的错一上来就直接套LightGBM然后疯狂调参连个基线都没建立最后模型出了好分数都不知道是好在哪里。我自己现在做项目一定先跑一个简单的线性回归或者决策树基线把数据质量、特征工程的问题暴露出来再上复杂模型。3.1 为什么要用LightGBM作为主模型在这个项目里我最终使用了LightGBM作为主力模型原因有三。第一是速度。训练集15万样本、200多列特征LightGBM用直方图算法训练一轮大约只要十几秒而同数据下XGBoost的hist树要慢不少这让我做调参实验的迭代效率高出许多。第二是它对类别特征原生支持。categorical_feature参数可以直接指定类别列自动处理类别变量的分裂方式省去手动编码的过程且不会因为one-hot引入稀疏性。第三是对缺失值不敏感。use_missing默认开启树模型分裂时会自动把缺失值分到增益最大的一侧这正好符合我们前面提到的缺失本身携带信息的假设。先跑一个最朴素的LightGBM基线固定所有超参数为默认值只设置objectiveregression和metricmae看看分数起点。import lightgbm as lgb params { objective: regression, metric: mae, learning_rate: 0.1, num_leaves: 31, max_depth: -1, min_child_samples: 20, feature_fraction: 1.0, bagging_fraction: 1.0, lambda_l1: 0.0, lambda_l2: 0.0, verbose: -1, seed: 42, } dtrain lgb.Dataset(X_train, labely_train, feature_nameauto, categorical_featurecategorical_columns) dvalid lgb.Dataset(X_valid, labely_valid, referencedtrain) model lgb.train( params, dtrain, num_boost_round10000, valid_sets[dvalid], callbacks[lgb.early_stopping(200), lgb.log_evaluation(100)], )这个基线在随机切分的验证集上MAE大约在0.42左右注意这是在log1p空间等价于原始价格空间的MAE大约在3.5万元到4万元之间。这个分数看起来不高但作为一个起点完全合格后面所有工作都是在这个基础上逐步压低的。3.2 调参顺序与核心参数的作用机理我在调参时遵循的是先粗后细、先结构后正则的顺序每一步只动一个维度通过少量实验枚举确定最佳区域再进行下一步。第一轮调的是树的结构参数num_leaves和max_depth。num_leaves是LightGBM最核心的控制变量它决定了每棵树的最大叶子数量叶子越多模型越复杂也就越容易过拟合。我用max_depth6固定深度然后让num_leaves在16到96之间枚举每次跑一个固定2000轮带早停的实验。最终num_leaves32附近分数最好过高到64以上时验证集MAE开始反弹过拟合的特征很明显。第二轮调的是采样参数feature_fraction和bagging_fraction。这两个参数的原理类似随机森林的列采样和行采样每次建树时随机挑选一部分特征和一部分样本增加模型多样性以降低方差。我将两个参数从1.0降到0.7发现验证集MAE下降的同时早停轮数也变多了说明泛化能力确实在提升。最终feature_fraction0.8、bagging_fraction0.8、bagging_freq1每轮都采样效果最好。第三轮调的是正则项lambda_l1、lambda_l2和min_child_samples。min_child_samples控制叶子节点最小样本数对此类稀疏特征多的数据集很有效。增大它可以让模型更稳健我把它从20调到40验证集MAE持平但训练集和验证集的差距缩小了这就是典型的降低方差收益。lambda_l2我试了1.0、5.0、10.0最后停在5.0附近lambda_l1设为0。下面是调参结果汇总表log空间MAE调整方向参数变化验证集MAElog结论基线默认参数0.421起点叶子数num_leaves 16→320.40332附近最佳叶子数num_leaves 32→640.408过拟合回退采样feature_fraction0.80.394有效采样bagging_fraction0.80.388有效正则min_child_samples400.385方差下降正则lambda_l25.00.383微幅提升3.3 学习率与迭代轮数的配合策略最后一个关键点是学习率和迭代轮数的配合。我采用的做法是先用一个较早的learning_rate0.05来确定大致的早停轮数然后再尝试更小的学习率0.02配合更大的num_boost_round看看有没有提升空间。实验结果很有意思learning_rate0.05时早停轮数大约在1200轮验证集MAE是0.381learning_rate0.02时早停轮数在3500轮左右验证集MAE是0.379。差距不大但训练时间差了将近两倍。所以在后续实验里我都用0.05作为默认学习率只在最后全量数据重训时才用0.02跑更长轮数以获得那一点点边际收益。这里有个小经验一定不要单独调num_boost_round要和learning_rate一起考虑。学习率越低每棵树对残差的贡献越小需要更多树来拟合同样的信号过早的早停设置会让你白白损失小学习率的优势。4. 模型融合从加权平均到Stacking的收益阶梯单模型跑到0.38log MAE左右时我意识到再往下抠LightGBM自身的调参空间已经不大了。这时候该上模型融合了。融合的本质不是把多个预测值平均一下这么简单它的收益来源是多个模型在不同样本上犯错的模式各不相同集成之后互相补位最终降低整体误差。4.1 保证基模型的差异化是融合的前提模型融合最重要的前提是基模型之间的相关性要低。这句话翻译成大白话就是两个模型犯一样的错融合它们没有意义两个模型各自在不同区域表现好融合才有价值。我在这套数据上看到过反面教材有人把两个参数几乎一样的LightGBM、一个XGBoost和一个CatBoost丢进Stacking里结果分数只提升了0.001就是因为四个模型都在同一个样本子集上系统性低估了豪车价格。为什么因为豪车样本少且价格方差大每个模型都没学好于是它们犯的错误高度一致。所以我在选择融合基模型时做了差异化设计基模型特点和差异点融合价值LightGBM调参后leaves-wise生长直方图算法快对稀疏类别友好主力提供稳定下限XGBoosthist树level-wise生长自带更精细的正则与LGBM生长方式不同相关性较低CatBoost对称树对类别特征有独特的目标统计编码在类别特征多的数据上常有奇效岭回归/ElasticNet线性模型对特征线性关系敏感树模型学不到的结构用线性模型补注意我把线性模型也放进来了这看起来有点反直觉。但实际上线性模型虽然整体误差更大它捕捉的是另一种完全不同的模式比如功率与价格近似线性的关系、车龄在短区间的线性折旧等树模型与线性模型的预测相关性通常明显低于树模型之间的相关性在融合里往往贡献不小。4.2 加权平均融合的权重寻优最基础的融合方式是预测值加权平均。这里不推荐人工拍脑袋定权重我用网格搜索对权重做了寻优。import numpy as np from sklearn.metrics import mean_absolute_error def search_weights(preds_dict, y_valid, step0.1): models list(preds_dict.keys()) best_score float(inf) best_w None n len(models) # 简单网格等间隔搜索所有权重组合限制和为1 for w in np.arange(0, 1 step, step): for v in np.arange(0, 1 step - w, step): weights [w, v, 1 - w - v] # 如果有多于3个模型这里可用递归生成组合 pred sum(preds_dict[m] * weights[i] for i, m in enumerate(models)) score mean_absolute_error(y_valid, np.expm1(pred)) if score best_score: best_score score best_w weights return best_w, best_score实际操作中我是先做LightGBM、XGBoost、CatBoost三模型的加权融合搜索步长0.05得到的最优权重大约是0.45/0.30/0.25本地MAE从0.379降到了0.361。接着把岭回归的预测也加进来重新搜索权重变成0.40/0.25/0.20/0.15MAE进一步降到0.355。加权融合的好处是简单、不容易过拟合坏处是它把每个模型的预测当作独立的点估计忽略了模型在不同样本上的置信度差异。想要继续提升就要上Stacking。4.3 Stacking融合的完整流程与细节Stacking的思路是用基模型的预测结果作为新的特征再训练一个元模型来学习如何组合这些预测。我用的是两层结构第一层四个基模型第二层一个简单的岭回归。具体操作流程是这样的将训练集划分为5折。对每个基模型在每一折的4折上训练预测剩下1折得到全量训练集上的OOFOut-Of-Fold预测。同时预测测试集取5次预测的平均值。将第一层的OOF预测作为新特征矩阵测试集预测平均值作为测试特征矩阵。用Ridge回归作为元模型在OOF特征上训练直接输出融合后的预测值。OOF机制是Stacking里最关键也最容易被做错的地方。如果不使用OOF而是让基模型在训练集上自预测来生成特征元模型看到的特征是作弊的因为基模型已经见过了这些样本的标签预测误差会异常小元模型学到的权重也就完全没有泛化意义。from sklearn.model_selection import KFold from sklearn.linear_model import Ridge NFOLDS 5 kf KFold(n_splitsNFOLDS, shuffleTrue, random_state42) # 假设 lgb_oof, xgb_oof, cat_oof, ridge_oof 都已经通过OOF循环生成 X_stack_train np.column_stack([lgb_oof, xgb_oof, cat_oof, ridge_oof]) X_stack_test np.column_stack([lgb_test_pred, xgb_test_pred, cat_test_pred, ridge_test_pred]) meta_model Ridge(alpha1.0) meta_model.fit(X_stack_train, y_train_log) stacked_pred meta_model.predict(X_stack_test)为什么元模型选岭回归而不是LightGBM因为第一层的预测已经高度非线性元模型只需要学习一个简单的加权组合线性模型最不容易过拟合。如果用树模型当元模型它可能会根据第一层预测的具体数值继续划分反而把OOF空间里的噪声也学进去了。而且OOF预测只有4个特征用线性元模型也更好解释每个基模型的贡献权重。最终Stacking在验证集上的MAE是0.348log空间换算成原始价格大约比最优单模型低了8%。到这里模型融合的三个层次我已经都展示完了从基线、到加权平均、再到Stacking每一步的提升都来自对模型差异性的挖掘而不是简单堆模型数量。5. 复现过程中那些容易让人返工的坑这个部分是我最想写的因为任何一份完美的项目代码背后都藏着一串让人头秃的bug和返工经历。这个二手车项目也不例外有几个坑如果不提前知道你可能要花好几天才能爬出来。5.1 数据泄漏的隐形陷阱目标编码全量统计目标编码我已经在前面提到了防泄漏方案但这里我要展开说一个具体踩坑经历。第一版特征工程里我图省事直接对brand做了全量均值编码没有做交叉验证内的编码。当时验证集MAE特别好看降到0.36左右我心里还挺高兴结果在赛题测试集上的得分比本地验证差了10%。回头一查才发现全量目标编码已经把验证集的价格信息教给了训练集模型在训练时学到的品牌编码值是包含未来信息的测试集里它面对的却是完全没见过的新车价格分布。从此我定了一个铁律凡是涉及标签信息的特征构造一律在交叉验证折内完成。不仅是目标编码包括用标签筛选异常值、基于标签做特征选择全部都要当成数据泄漏来处理。5.2 同品牌分组划分验证集随机划分的陷阱第二个坑和验证集划分有关。随机K折在大多数情况下没问题但在这个项目里同一款车型往往集中在相近的年份和地区随机划分会把同一个车系的数据拆散到训练和验证里。我做过一次对比实验把随机划分改成按品牌分组划分GroupKFold保证同一个品牌的车不会同时出现在训练和验证集里结果验证集MAE从0.38直接涨到0.46。这说明随机划分下的本地验证分数是有水分的模型记住了一些品牌特征的表象而不是泛化规律。这个问题的教训是本地验证分数的相对变化可以用来调参但它的绝对值不等于测试集分数。最终提交前务必多跑几种划分方式做交叉验证看分数是否稳定。如果某一种划分方式让分数剧烈波动说明模型稳定性不够需要从特征层面找问题。5.3 缺失值语义的负样本陷阱第三个坑是关于notRepairedDamage的。字段里-代表无修复记录0或1代表是否有损伤。第一版我把它用fillna(-)填了但没注意新填充的-是不是真的和其他-含义一致。后来我又加了used_time特征结果发现缺失notRepairedDamage的样本和车龄较大的样本高度重叠说明这个字段的缺失并不是随机的它本身就暗示了车龄。如果我用均值填充或者简单的众数填充就是在用整车年龄这种衍生变量来替代原始缺失信号造成信息混淆。最终我保留了缺失值本身作为独立类别让树模型自己决定怎么分裂。这个原则可以泛化到所有类别特征的缺失处理先搞清楚缺失的原因再决定填充策略不要上来就fillna。5.4 目标值log变换后的还原与验证最后一个实操建议是关于最终结果的还原验证。我在每次提交前都会做一遍完整的还原检查流程是把log空间的预测做np.expm1还原成原始价格然后统计还原后预测价格的分布跟训练集真实价格分布做对比确认没有大面积负值或者极端异常。这里要提醒一下在log空间训练模型模型天然倾向于输出中间区域的预测还原后会出现对高价值车辆的保守低估和对低价值车辆的高估。这不是bug这是log变换的固有偏差。如果你的业务场景对豪车的预测精度要求更高可以考虑对价格按分位数做分段建模比如把价格分3段分别训练3个模型再用分类器决定样本属于哪一段。我试过这个方法MAE分数上略有提升但模型复杂度翻了三倍最后没有作为主方案算是一个可选的进阶思路。5.5 全量重训与最终提交的稳定策略调参确定后最后一步是在全量训练数据上重训模型用早停得到的树数量或稍大一点的树数量来保证泛化。四个基模型分别在全量数据重训后各自预测测试集再用之前Stacking得到的元模型权重融合。我在重训时还会固定随机种子并把交叉验证里的多个模型权重求平均再做融合这样能够降低单次训练的随机性。最终提交的融合结果在整个测试集上的表现和本地CV高度一致说明整个流程是稳定可信的。6. 结语这个项目带来的几个可复用经验这个二手车价格预测项目练下来我个人最大的收获不是某个具体参数怎么调而是一套完整的建模思维方式。每次拿到新的表格型数据我现在都会按照先数据体检、再特征工程、然后基线模型、基于基线调参、最后模型融合的顺序走每一步都先想清楚为什么再动手写代码。这种思路同样适用于房价预测、销量预测、风控评分卡等回归类项目。分享一个实操小技巧在Stacking融合阶段我会顺手把四个基模型的OOF预测相关性矩阵打印出来看一眼哪两个模型的相关性最高如果超过0.95我会考虑砍掉其中一个因为它提供的信息和另一个重复了留着只会在元模型里引入噪声。这个检查在别的融合场景里也一直沿用。如果你打算把这个项目写进简历或者作为面试项目来讲建议一定要亲手跑一遍完整流程把每一步的特征含义、参数作用和融合原理讲清楚。面试官最喜欢追问的点往往不在模型本身而是你为什么这个特征要这样处理以及融合到底带来了多少提升、为什么有提升这些答案不在任何文档里只有亲手实操过才有底气回答。代码和数据我整理一份放在项目目录里需要的朋友可以直接参考复现调参那部分记得结合自己的机器配置适当调整学习率和迭代轮数不必完全照搬。