☰
二手车价格预测实战:五折交叉验证如何稳定评估与提升线上成绩
2026/10/3 9:02:07 网站建设 项目流程

今年阿里AI大赛上我选了二手车价格预测这个赛道。从初赛到终榜折腾了将近两个月,最后名次不算高,但最大的收获反而是最基础的东西——五折交叉验证。以前总觉得交叉验证是每个教程都会写两笔的小事,等真正搭进比赛流程里,才发现这套不起眼的基础设施直接决定了本地评估稳不稳、线上能不能复现。这篇不打算复述调参全家桶,就按我踩坑的顺序聊聊:赛题怎么理解、五折为什么是性价比最高的评估方式、工程实现时容易翻车的细节,以及它如何撑起后面的调参和融合。

1. 赛题本质与衡量标尺:从业务问题到回归任务的转换

1.1 先想清楚:业务要的是价格,模型要的是分布

二手车价格预测,表面上是给一辆车输出一个价格数字,实际是在“车况完全不可见”的前提下估计市场成交价格的期望。比赛中拿到的特征大多来自车辆本身的静态描述:登记日期、新车指导价、上牌城市、过户次数、累计里程、排量、变速箱、排放标准,真正影响成交的车况、事故记录、保养历史要么缺失、要么根本不在字段里。所以模型能学会的不是一个确定的价格,而是给定特征后的价格条件分布;与其叫预测,不如叫估计中位数或均值。这个认知决定了后面很多选择,比如要不要对目标做变换、用 MAE 还是 RMSE 作为关注重点、以及交叉验证应该看整体分数还是分位数表现。

我先把 train 和 test 的字段完整拉出来看了一遍,没有急着写第一版模型。二手车数据的脏点相当统一:里程有 0 也有异常大值;上牌年份偶尔出现未来年份;新车指导价有的单位是万元、有的单位是元;城市字段存在简称、全称、历史名称混用。这些数据问题不处理,五折交叉验证做得再精细也只是在脏数据上自嗨。我的习惯是先做一张字段处理表,对每个特征记录缺失率、类型、清洗规则和创建方法,后续每次迭代都对照这张表更新。比赛后期回头看,这个动作帮我省掉了至少两次线下高分线上翻车的排查时间。

1.2 评估指标决定了要不要对价格做变换

赛题排名指标是绝对误差 MAE。MAE 对绝对价格误差一视同仁,不会因为高价位样本偏差大而放大惩罚,所以目标变量没有做 log1p 变换的必要。如果指标换成 RMSE 或 MSLE,情况完全不同:平方误差会让百万级豪车的一个错误预测主导整体分数,往往需要先对价格取对数再做回归;如果指标是 MAPE,低价车的相对误差会被严重放大。当时群里不少选手一上来就无脑 log1p,本地集怎么调都下不去,最后发现指标根本不匹配。

评估指标对误差的敏感方向是否需要目标变换在二手车场景的常见做法
MAE所有绝对误差同等权重一般不必须直接回归,适合稳定评估
RMSE大额误差被平方放大可考虑 log1p高价车精度更容易影响分数
MSLE高价格样本误差权重更大需要 log1p与 RMSE 类似但更偏比例
MAPE低价样本相对误差被放大需要处理零值少用于价格绝对值赛题

这个表是我在初赛阶段反复核对后确定的。核心逻辑很简单:交叉验证的唯一意义是模拟排名指标,如果本地评估指标和线上指标不一致,那 CV 给出的一切结论都是噪声。

1.3 先评估“可预测性”,而不是直接堆模型

在搭五折骨架之前,我先看了价格的分布形态。典型二手车价格呈右偏长尾,从近万元的代步车到上百万元的高端车都有,尾部虽然数量少,却会显著影响折与折之间的 MAE。如果直接用随机 KFold 切分,某一折可能分到更多高价车,另一折可能以低价车为主,折间分数差距会由样本构成决定,而不是模型能力差异。这个观察直接推动了我后面选择“按价格分箱后再分层五折”的方案。

换句话讲,交叉验证的选择本身也需要先看数据,不能照抄分类比赛里那套 StratifiedKFold 的用法。分类任务可以直接按标签分层,回归任务没有现成的类别标签,但我们可以构造出价格带。这里提前做个预告:在数据量几十万、价格强长尾的场景里,分箱分层后的五折,折与折之间分数更紧凑,线上复现率也更高。

2. 为什么最终选择五折交叉验证,而不是单留出集或时间序列切分

2.1 单次留出验证集,等于把排名交给随机数

比赛初期我图省事,用 train_test_split(test_size=0.2) 跑基线。结果有次只是把 random_state 从 42 换成 2024,本地 MAE 就从 5540 跳到了 5480,差了足足 60 块;而同一个版本跑五折交叉验证,均值变化只有个位数。对于二手车价格这种噪声很大的交易数据,单次切分出来的分数根本分不清改进是来自特征还是纯粹来自划分运气。

从那天之后,所有正式结论都以五折均值为准,留出验证集只用来快速确认代码能不能跑通。这个转变看起来保守,实际是被随机数教育过之后最有效的选择。我后来还会在群里看到有人用单次留出集对比两版特征,结论差异很大,原因就是验证集噪声没有被平均掉。这类场景,五折交叉验证省下的不是训练时间,而是走弯路的时间。

2.2 为什么是五折,而不是三折或十折

三折的训练集占比太低,只有 67% 左右,模型容易欠拟合,而且验证集占三分之一,折间差异天然偏大;十折折与折之间重复样本多,理论上更稳,但训练时间接近五折的两倍,在特征迭代频繁的比赛里很拖节奏。我有过一个实际对比:同一组特征在 10 万行训练集上,五折均值与十折均值相差不到 0.3%,但十折耗时接近 1.8 倍。五折在这种量级下是性价比最明显的拐点。

方案训练占比折间重叠计算成本稳定性
单留出80%无1倍受随机划分影响大
三折67%低约3倍偏差略高
五折80%中约5倍均值稳定,折间可分析
十折90%高约10倍最稳,但耗时
重复五折80%可配置约5×重复次数倍最稳,适合小数据集

如果数据只有一两万行,我会优先上十折或重复五折;如果数据量到了百万级,五折基本是默认选择。选五折不是因为听起来顺口,而是在比赛这种强调迭代速度的环境里,五折是“方差控制”和“算力成本”之间的甜点区。

2.3 有时间列,但不要无脑 TimeSeriesSplit

二手车数据通常带注册年份、上牌月份甚至拍卖日期,很多人第一反应就是按时间切片做验证。我专门对比过:TimeSeriesSplit 下的本地 MAE 比随机 KFold 明显偏高,而且线上表现与随机 KFold 的排序更一致。原因不复杂,赛题要预测的是“当前行情下一辆车的市场价”,并不是“下个月的价格”。车龄、新车指导价、排放标准这些字段已经把时间效应编码成静态特征了,不需要再假设训练集和测试集之间存在严格的时间先后关系。

如果担心价格随宏观经济偏移,更好的处理是把上牌年份、车型车龄和保值率曲线做成交叉特征,而不是改动验证方式。这一点我踩过小坑:一开始觉得所有带时间的比赛都该用时间序列验证,结果本地 CV 和公共榜对不上,最后切回按行随机 KFold 才恢复正常。当然,如果赛题明确要求预测未来时间窗口的价格,那 TimeSeriesSplit 就是必须,只能说二手车这个场景更适合随机划分。

2.4 五折自带的集成效应:免费拿到 OOF 和 Bagging

很多人把交叉验证只当成评估手段,其实它同时也是集成手段。五折训练会产生五个独立模型,预测阶段把五个模型的输出取平均,能自然平滑单模型的波动,效果约等于一个小型 Bagging。更关键的是 OOF 预测:每一折训练时,被留在折外的 20% 样本都会被当前模型预测一遍,五折跑完,训练集里每个样本都有且仅有一个“看不见自己的模型”给出的预测值。

这份 OOF 是后续做融合、做第二层 Stacking 的黄金输入,远比直接用训练集预测训练集靠谱。比赛后期我用 LightGBM、XGBoost、CatBoost 三个模型各自的五折 OOF 拼接起来,喂给一个岭回归做第二层,线上分数又往前挪了一小截。这一整块收益,基本都来自把五折跑规整了。

3. 五折交叉验证的工程化骨架:从OOF到多模型加权预测

3.1 一个可以复用的五折训练循环

我建议把交叉验证写成一个固定函数,而不是在 notebook 里复制粘贴循环。下面是我在比赛里使用的 LightGBM 版本骨架,核心是保存每折模型、收集 OOF、返回五折平均预测:

import numpy as np import pandas as pd import lightgbm as lgb from sklearn.model_selection import KFold from sklearn.metrics import mean_absolute_error FEATURES = [...] # 统一由特征工程模块生成 TARGET = 'price' N_SPLITS = 5 SEED = 42 def lgb_model(X_tr, y_tr, X_va, y_va): dtrain = lgb.Dataset(X_tr[FEATURES], y_tr) dvalid = lgb.Dataset(X_va[FEATURES], y_va) params = { 'objective': 'regression', 'metric': 'mae', 'learning_rate': 0.05, 'num_leaves': 31, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'verbosity': -1, 'seed': SEED, } model = lgb.train( params, dtrain, num_boost_round=3000, valid_sets=[dvalid], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)] ) pred = model.predict(X_va[FEATURES], num_iteration=model.best_iteration) return model, pred kf = KFold(n_splits=N_SPLITS, shuffle=True, random_state=SEED) oof = np.zeros(len(df)) pred_all = np.zeros(len(test)) models = [] for fold, (tr_idx, va_idx) in enumerate(kf.split(df)): tr, va = df.iloc[tr_idx], df.iloc[va_idx] model, pred = lgb_model(tr, va, va, va[TARGET]) oof[va_idx] = pred pred_all += model.predict(test[FEATURES]) / N_SPLITS models.append(model) print(f'fold {fold} MAE:', mean_absolute_error(va[TARGET], pred)) print('OOF MAE:', mean_absolute_error(df[TARGET], oof))

这个函数的要点有两个。第一,每一折都独立早停,因此五个模型的迭代轮数可能不一样,不要强制统一;第二,test 预测用的是折内平均,而不是某个单折模型,这能减少随机性带来的波动。后面所有模型(XGBoost、CatBoost、神经网络)都沿用同样的接口,只是替换 model_fn,工程上非常省事。

3.2 回归任务的分层变通:按价格分箱后再 StratifiedKFold

直接对连续目标用 KFold,可能出现高价车在某几折里过密、在另几折里缺失的情况。更稳的做法是先把价格分成若干档位,再用分类分层切分。我自己是按十分位做分箱,然后调用 StratifiedKFold:

from sklearn.model_selection import StratifiedKFold df['price_band'] = pd.qcut(df[TARGET], q=10, labels=False, duplicates='drop') skf = StratifiedKFold(n_splits=N_SPLITS, shuffle=True, random_state=SEED) for fold, (tr_idx, va_idx) in enumerate(skf.split(df, df['price_band'])): tr, va = df.iloc[tr_idx], df.iloc[va_idx] # 后续训练逻辑与上一节完全一致

这种分层能保证每一折里低价、中价、高价车的比例和全集接近。拆分出来的折间 MAE 标准差通常会比纯随机 KFold 小不少,更重要的是,每折分数差异不再被目标分布的变化掩盖,模型本身的稳定性更容易被看见。需要提醒的是,分层只是让每折的价格构成一致,并不能解决特征分布偏移的问题;如果某些品牌或车型在特定折中特别多,还是要靠业务理解去判断。如果怕单一分箱方式太主观,可以多试 q=8 和 q=12,看折间标准差变化,选一个更稳的。

3.3 OOF指标、折间稳定性与预测值融合的取舍

五折跑完之后,我习惯同时看三个东西:OOF 整体 MAE、每折 MAE 的均值和标准差、以及 OOF 在不同价格区段上的误差分布。OOF 整体 MAE 是每天都看的核心数字;每折标准差反映了模型对样本构成的敏感度。一次折间标准差从 70 降到 30,虽然整体均值变化不大,我也会记录下来——它通常意味着模型对少量极值样本的依赖变小了。

价格误差按区段拆开看,会发现很多有趣的问题。比如低价车的 MAE 可能只有几百块,高价车却动辄上万;如果高价车只集中在某几折,全局 MAE 就会被这几折主导。我的处理方式是针对高价车额外加一个分层权重,或者单独训练一个高价区间的子模型,最后用规则融合。这些判断全部依赖五折提供的分折明细,单次留出验证根本给不了这么细的信息。

预测融合上,我对比过五折预测的均值和中位数:在 MAE 指标下,中位数往往不比均值差,因为价格长尾里有少数极端预测会被平均拉偏。最后提交前我会两个都试,哪个 OOF 低就用哪个。

3.4 推理阶段最容易翻车的一致性细节

五折交叉验证最常见的翻车点不在训练,而在推理。第一是特征列表不一致,训练时用 FEATURES 列表里的 80 个特征,测试却因为排序或预处理差异变成了 79 个,LightGBM 有时不报错,只是预测结果全乱;第二是类别特征的编码空间不一致,某个车型只在训练集出现、测试集里完全没有,或者反过来,模型在五折之间拿到了不同的类别字典。

我的做法是在训练循环之前就统一一次类别编码:把所有类别特征先 factorize 成整数,并确保 train/test 的取值都落在同一套映射里。分类算法的 categorical_feature 参数能省不少事,但前提是每一折拿到的是同一套类别编码。另外,模型的保存和加载也要按折管理。推荐命名方式就是model_{seed}_fold{fold}.txt,提交阶段统一加载并平均,避免 notebook 内存里残留旧模型导致的“神秘线上分数”。这个细节我反复强调给队友,因为比赛到后期,大家很容易在多次 kernel 运行后把不同版本模型混在一起。

4. 调参与特征筛选:交叉验证在日常迭代中的正确用法

4.1 单验证集调参,就是和噪声谈恋爱

假设你在一个固定验证集上调了三组参数,验证集的 MAE 分别下降 10、8、12,看起来第二组也不错。但验证集本身有噪声,这三组差异很可能只是运气。单验证集反复用,时间一长,模型会被“剪裁”到适配这 20% 样本的噪声,线下分数越调越漂亮,线上却纹丝不动甚至倒退。

五折调参的代价是训练五次,但换取的是更接近真实排名的判断依据。我给自己定的规矩是:任何参数结论至少要在五折均值上对比,如果两版本差异小于折间标准差,就认为没有显著提升,不值得采纳。这个“折间标准差对照法”帮我砍掉了很多无效参数组合,也避免了不少自我感动。

4.2 每折独立早停,而不是统一固定迭代轮数

我第一次跑五折时,直接在全集上先定一个 num_boost_round,然后每折都用这个固定轮数训练。结果折间分数波动很大,最后发现原因很简单:不同折的验证集构成不同,最优早停点本来就应该不同。正确做法是每折都设置 early_stopping,各自停在 best_iteration 上。虽然这样五折模型的轮数各不相同,但每个模型都是该折验证集上的最优解,整体 OOF 才是公平的。

需要注意的是,early_stopping 的 patience 要和学习率匹配。学习率设成 0.01 时,100 轮 patience 可能不够;学习率设成 0.1 时,100 轮可能又太多。我一般固定 learning_rate=0.05,patience=100,先跑一折看实际迭代轮数落在什么区间,再决定要不要调。不要一上来就训练 10000 轮等早停,那样很浪费时间。

4.3 固定折划分,特征对比才有效

比赛迭代过程中,我会反复试验新特征。如果每跑一次都重新生成随机折,特征 A 比特征 B 好了 5 块,很可能只是这次划分运气更好。解决办法是固定一套折索引,所有特征实验都在同一套划分上对比,只有最终确认特征有效后再换种子验证一遍。固定折划分后,线下结论的稳定性会明显提升。

我对每个数据版本都会缓存fold_index.npy,训练循环直接读缓存,保证从早到晚跑的是同一套五折。这里有个小技巧:折索引的生成要基于原始数据顺序,不要基于每轮清洗后的数据顺序,否则一旦删行或改排序,折索引就全错位了。

4.4 特征重要性要跨折平均,最好再配合置换验证

单个模型的特征重要性受该折数据影响很大,尤其当某个类别特征只在某折里大量出现时,重要性会出现假高。我会把五折模型的 feature_importance 按 gain 归一化后求平均,得到一个相对稳定的排序。更严格的筛选方式是做置换或 drop-column 验证:对候选特征做随机打乱,比较 OOF MAE 变差多少。

五折上做置换代价不低,但能筛掉不少“看着重要、实际没用”的特征。比赛后期我靠这种方法把特征从 116 个砍到 83 个,本地 OOF 略微下降,线上稳定性却明显好了。特征数量减少还顺带降低了后续 Stacking 的过拟合风险。需要说明的是,置换验证的结果要和折间标准差结合看,如果某特征打乱后 OOF 只变差 2 块,而折间标准差本来就有 5 块,那这个特征的可信度就要打个问号。

5. 踩过的坑、线上差异与最终复盘

5.1 本地分高但线上崩:先查业务泄漏,再看验证方式

有一版特征让我印象深刻,本地 OOF 直接从 5380 掉到 5200,提升接近 3%,当时高兴坏了。提交上去,线上分数反而掉了 100 多。排查下来,发现我构造了一个“车辆后续有过几次过户”的特征——这笔信息在业务上是交易之后才产生的,模型能拿到说明字段本身存在未来信息,也就是典型泄漏。五折交叉验证不会替你识别这类问题,它只负责在给定特征下估计误差。

每次追加新特征,我都会问三件事:这个字段在预测时间点真的存在吗?它的值会不会受到成交结果影响?如果它是人工统计出来的汇总值,统计窗口是否覆盖了待预测时刻?这三关都过了,才敢让五折来打分。比赛里很多本地爆分、线上崩盘的案例,最后追到底都是这类业务泄漏,而不是模型训练问题。

5.2 公共榜分数和本地CV错位:看排序,不看绝对值

比赛中间有段时间,我的本地 OOF 连续两周往下走,公共榜却一动不动,一度怀疑自己烧错特征。后来复盘发现,公共榜本身是隐藏测试集的一部分,样本量不大,存在着不小的随机波动;不同版本之间本地差 20 块,线上可能反向差 30 块。这时候更可靠的参照是提交版本在公共榜上的相对排序,而不是绝对分数。

我会把每个版本记成一行:本地 OOF、折间标准差、公共榜分数、提交时间。版本多了以后,用排序一致性判断哪些改动真正有效。五折在这里的贡献是把本地评估的噪声压到足够低,让排序至少有参考价值。如果本地评估本身噪声很大,版本之间的公共榜排序就完全失去意义。

5.3 计算开销上来之后,交叉验证也要分层级

五折训练开销是单模型的五倍,特征上百、参数搜索范围又大时,很容易陷入“改一个参数要等半小时”的节奏。我的经验是把实验分成三层:第一层用单独一次留出集跑快速筛选,只用来排除明显无效的方向;第二层用五折跑完整评估,确认提升是否稳定;第三层才是换不同随机种子重复验证,为最终融合提供多个模型。不要每次改动都直接上五折,也不要因为单留出集结果好就直接提交,分层级反而能让你在有限算力下试更多想法。

这个经验对没有 GPU 的选手尤其重要。我打比赛时主要靠 CPU 跑 LightGBM,特征维度到 100 以后,一组五折就要 15 到 20 分钟,如果不分层级,一个晚上根本试不了几个想法。先留出集粗筛,再五折确认,最终换种子的三层结构,基本成了我后面每次比赛的固定工作流。

5.4 提交前检查清单:五个模型平均还是三个种子平均

最终模型我用了两套随机种子(42 和 2024)分别跑五折,总共 10 个模型,再和 XGBoost、CatBoost 的 OOF 做岭回归融合。提交前会严格检查三件事:训练和测试的特征列名顺序是否一致;每折预测平均时是否除以了正确的模型数量;提交文件的 index 是否和测试集完全对应。看起来都是小事,但我在最后一天确实遇到过加载旧权重导致线上分数倒退的情况。

建议写一个infer_ensemble.py脚本,只做一件事:加载固定目录下的模型列表,输出提交文件。不要用 notebook 里的临时变量做最终提交,因为你永远不知道哪次运行把某个模型覆盖了。这个脚本也可以复用于不同种子、不同模型,后期融合全靠它,能极大减少手工操作带来的低级错误。

5.5 一点额外的体会:五折交叉验证在这个赛题里不只是评估工具

整个赛程下来,我最深的体会是,五折交叉验证在二手车价格预测里承担的不只是“评估谁更好”这个职能。它同时在扮演特征质量审计、模型集成器、调参防过拟合器和最终融合数据源。本地和线上的每一次错位,最后几乎都能追溯回交叉验证某个环节没做扎实——要么分层不对,要么折索引不固定,要么推理阶段特征顺序错了。如果你也在打类似的表格型比赛,建议先把五折这套地基打牢,再追求高阶技巧;地基稳了,排名不会差到哪里去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询