ARTICLE DETAIL

资讯详情

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

二手车价格预测实战:特征工程与MAE优化全流程解析

二手车价格预测实战:特征工程与MAE优化全流程解析 简介阿里天池与Datawhale联合举办的二手车交易价格预测竞赛中这份优胜奖方案提供了从特征工程、模型训练到结果融合的完整实现覆盖数据预处理至最终预测输出全过程。面向具备Python编程与数据分析基础的高校学生、研究人员可作为课程设计、毕业设计或学科竞赛的参考范例。压缩包共15个文件以8个Python脚本为核心覆盖lgb_model、nn_model、cab_model及stackmix等建模模块另含预测结果CSV、README说明文档与备份文件整体约610KB。代码按特征生成、单模型训练与Stacking融合分层组织结构清晰下载后可直接复现预测流程。目前已有42人学习浏览通过源码可掌握表格型数据挖掘中的特征构造、模型集成与调参思路若需进一步扩展则需深入理解各模块逻辑并结合自身数据场景修改优化。1. 二手车价格预测竞赛MAE 压到最低的那批方案都做对了什么二手车价格预测是阿里天池与 Datawhale 合作组织过的经典回归赛题输入是车辆的静态属性——品牌、上牌日期、表显里程、功率、变速箱、维修损伤标记等输出是二手车的交易价格官方用 MAE平均绝对误差打分。这个赛题不像图像或 NLP 那样拼模型结构数据量也就十几万条反而更像工业里最常见的“表格回归”谁的数据处理更细、验证策略更稳谁就能拿到优势名次。很多选手用 LightGBM 就能跑到还不错的分数但真正拿优胜奖的方案差别通常藏在特征构造和数据泄漏的防护上。这篇文章把整套方案按“数据清洗 → 特征工程 → 模型融合 → 调参 → 踩坑 → 后处理”的落地顺序拆开讲写清每一步的代码和参数逻辑你可以直接照着在自己的环境里复现。2. 数据清洗与字段体检二手车数据一上来就有三个陷阱2.1 读表与字段说明先分清有多少信息是可用的赛题给的是车辆静态快照不是时序数据所以特征主要靠“挖掘字段之间的关系”。我先说字段因为后面所有特征构造都建立在字段理解上。训练集和测试集的主要字段大致包括SaleID样本 ID、name车型编码枚举值很多、regDate上牌日期8 位整数如 20150301、model型号编码、brand品牌编码、bodyType车身类型、fuelType燃油类型、gearbox变速箱、power功率、kilometer表显公里、notRepairedDamage是否有未修复损伤、regionCode地区编码、seller卖家、offerType报价类型、creatDate广告创建日期、price交易价格只有训练集有。其中 offerType 在绝大多数样本上都是同一个值几乎不带信息seller 的有效取值也极少这两列我一般直接删掉留着只会让树模型多分几层却没收益。读数据和体检的代码这样写import pandas as pd import numpy as np train pd.read_csv(used_car_train.csv, sep ) test pd.read_csv(used_car_testA.csv, sep ) print(train shape:, train.shape) print(test shape:, test.shape) print(train.isnull().sum()[train.isnull().sum() 0]) print(train[price].describe())train 有 pricetest 没有这是赛题预设的。先看isnull().sum()过滤出真正有缺失的列再看 price 的min、max、mean判断是否存在极端值。你可能会发现 notRepairedDamage 这一列在缺失统计里没有出现但它不是没有缺失而是缺失值被写成了字符串 nan这个问题在 2.2 里单独说。2.2 三个一上来就骗人的脏数据第一个陷阱是 notRepairedDamage。pandas 读进来时这列里的缺失被写成了字符串 nan它不是真正的 NaN所以isnull()检测不到模型会把它当成一个独立的类别去切分白白浪费分裂点。处理方式是显式替换train[notRepairedDamage] train[notRepairedDamage].map({nan: 0, 1.0: 1, 0.0: 0}) test[notRepairedDamage] test[notRepairedDamage].map({nan: 0, 1.0: 1, 0.0: 0})第二个陷阱是 power 里存在大量 0 值。物理上一个正常的车功率不可能是 0这些样本往往是数据录入缺失或者特殊车型比如某些电动车没有按燃油车口径记录。不要直接删更不要用全局中位数填。我一般先按 brand 和 model 分组用组内中位数填充同时加一个power_is_zero的布尔特征把“缺失”这个信息本身交给模型。第三个陷阱是 bodyType、fuelType、gearbox 这类枚举列有少量缺失。常见做法是用 -1 填充再额外加一列 mask 标记是否缺失。这样树模型可以学习到“缺失”本身和价格的关系而不是被强行平滑掉。做完之后把 seller、offerType 这两列 drop 掉训练集和测试集保持同样的操作。2.3 价格分布与 log1p先决定训练目标的形态价格列是典型的右偏分布几万块的样本占大多数但也有几十万甚至上百万的样本。直接拿原始价格做回归模型会把大量精力花在拟合少数极端高价样本上MAE 反而不稳。常见做法是对 price 做 log1p 变换让目标分布更接近正态train[price_log] np.log1p(train[price])训练模型时用 price_log 作为 target提交前用np.expm1还原成价格。这里有个很容易翻车的点不要在 log 空间里直接比较验证集的误差。log 空间的 MAE 小不代表还原到价格空间后 MAE 就小因为 exp 会把大价格样本的误差放大。所以验证代码里永远是“先还原、再算 MAE”这一点在第五章还会展开。3. 特征工程真正拉开差距的往往不是模型而是这 30% 的工作3.1 车龄与里程二手车价格的两个硬指标怎么做成特征车龄是二手车定价最强的锚点。数据里 regDate 和 creatDate 都是 8 位整数format 类似 20150301直接减没有意义要先转成 datetime。车龄我一般用广告创建日期减去上牌日期单位是天再转成年。这个差值可能为负原因可能是上牌日期在创建日期之后属于数据录入错误统一截断为 0 或直接按缺失处理。代码def parse_date(s): s s.astype(str) return pd.to_datetime(s, format%Y%m%d, errorscoerce) train[reg_date] parse_date(train[regDate]) train[create_date] parse_date(train[creatDate]) train[age_days] (train[create_date] - train[reg_date]).dt.days train[age] np.clip(train[age_days] / 365.0, 0, 20) test[reg_date] parse_date(test[regDate]) test[create_date] parse_date(test[creatDate]) test[age_days] (test[create_date] - test[reg_date]).dt.days test[age] np.clip(test[age_days] / 365.0, 0, 20)注意.dt.days在遇到缺失时会变成 NaN.dt访问器只对 datetime64 序列有效如果你在 Series 上直接.days会报错这也算一个常见的小坑。age 截断到 20 是经验值超过 20 年的车在价格上已经进入平台期继续保留原始天数会让模型在尾部过拟合。里程 kilometer 我保留原始值同时做一个分箱特征分箱边界可以取 0、1、2、5、10、20 万公里这样的整数段分箱的目的是让模型更容易捕捉里程的阶梯效应。age和kilometer的缺失或异常在这个阶段不急着处理交给后续的填充策略。3.2 高基数类别列的压缩频率编码比 label encoding 稳得多brand、model、name 都是枚举类型其中 name 的取值数量可能接近上万。直接把 name 丢给 LightGBM 做 label encoding会得到一串没有单调含义的整数树模型虽然能切分但对稀有类别的泛化很差。我一般对这类列做 frequency encoding用“这个类别在训练集中出现的次数”作为特征因为出现在二手车市场里的车型其交易量本身就隐含了保有量、流通性等信息。代码for col in [name, model, brand]: freq train[col].value_counts() train[col _freq] train[col].map(freq) test[col _freq] test[col].map(freq) train[col _freq] train[col _freq].fillna(0) test[col _freq] test[col _freq].fillna(0)测试集里可能出现训练集没见过的 name 或 modelmap之后是 NaN必须 fillna(0)否则模型预测时会把缺失当成一个特殊值影响分裂。这里还有一个更稳的变体把 name 与 brand 拼成组合列再做频率编码因为同一个 name 编码在不同的 brand 下含义不同组合特征能让频率值更精准。3.3 交互特征与统计特征加对了是提分加多了是玄学树模型本身能做特征组合所以交互特征不是越多越好我倾向于只加有业务含义的。常用的几个年均里程kilometer / (age 1)表示车况损耗强度功率车龄比power / (age 1)近似描述动力衰减milage与power的比值可以粗略反映车型级别。还要注意 regionCode 地区编码这个列的取值多但业务含义强不同城市二手车的价格指数差别不小。可以对 regionCode 做目标编码但必须防泄漏做法是在 K 折内部对每一折单独计算平均值绝不能用全量训练集算完再直接填。from sklearn.model_selection import KFold train[region_mean] np.nan kf KFold(n_splits5, shuffleTrue, random_state42) for tr_idx, va_idx in kf.split(train): mean_map train.loc[tr_idx].groupby(regionCode)[price_log].mean() train.loc[va_idx, region_mean] train.loc[va_idx, regionCode].map(mean_map) test[region_mean] test[regionCode].map( train.groupby(regionCode)[price_log].mean() )这里对训练集用折内均值测试集才允许用全量均值因为测试集没有标签。如果你在训练集上也直接 groupby 全量均值验证分数会虚高。特征做到这一层数量已经足够再多就要开始观察特征重要性排名把那些排在后 20% 且与业务无关的特征删掉避免给模型喂噪音。4. 模型与融合LightGBM 起步、三件套加码、Stacking 收尾4.1 为什么树模型是这个赛题的默认起点表格类回归任务里Gradient Boosting Decision Tree 基本是标配。原因有三一是特征量纲不敏感power 和 kilometer 相差几个数量级树模型按阈值切分不需要归一化二是天然支持混合类型离散枚举和连续数值可以同时进入训练三是对异常值鲁棒单棵树的叶子输出使用均值或中位数受极端样本影响比线性模型小。在这个赛题里我一般把 LightGBM 作为第一版 baseline跑通之后再用 XGBoost 和 CatBoost 去对比最后做融合。4.2 先跑通一版 LightGBM 最小代码特征构造完成后把数值列填好缺失直接进入训练。这里用 sklearn API 还是原生 API 都行我习惯用原生 API因为 early stopping 和 valid score 的控制更直观import lightgbm as lgb from sklearn.model_selection import KFold features [c for c in train.columns if c not in [SaleID, price, price_log, regDate, creatDate, reg_date, create_date]] X train[features] y train[price_log] params { objective: regression_l1, metric: mae, learning_rate: 0.05, num_leaves: 63, max_depth: -1, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, verbose: -1, seed: 42, } kf KFold(n_splits5, shuffleTrue, random_state42) oof np.zeros(len(train)) for tr_idx, va_idx in kf.split(X): dtr lgb.Dataset(X.iloc[tr_idx], y.iloc[tr_idx]) dva lgb.Dataset(X.iloc[va_idx], y.iloc[va_idx]) model lgb.train( params, dtr, num_boost_round3000, valid_sets[dva], callbacks[lgb.early_stopping(100), lgb.log_evaluation(0)] ) oof[va_idx] model.predict(X.iloc[va_idx], num_iterationmodel.best_iteration) mae np.mean(np.abs(np.expm1(oof) - train[price].values)) print(offline MAE:, mae)objective 用的是regression_l1也就是直接优化 MAE 对应的绝对值损失而不是默认的 L2 平方损失。价格预测里极端值多L2 会把误差集中在大价格样本上L1 更贴合官方指标。验证时先expm1还原再算 MAE这一步会反复强调因为它是整个验证体系里最容易写错的地方。early stopping 的 100 轮是经验值learning_rate 设为 0.05 时100 轮以内的过拟合风险还在可控范围。4.3 三件套的差异与 Stacking 的防泄漏设计XGBoost、LightGBM、CatBoost 在这个赛题上的差距通常不到 0.5%但融合后能再压下来一点。XGBoost 对缺失值有自己的学习路径适合保留原始 NaN 不做填充的列CatBoost 对高基数类别列支持内置 target encoding处理 name、model 这类列时可以把 order 打开避免我手写频率编码的信息损失。如果你时间有限优先把 LightGBM 的 oof 和 CatBoost 的 oof 融合即可。融合用简单加权平均就能拿到大部分收益权重在验证集上搜索from scipy.optimize import minimize def loss(w): return np.mean(np.abs(np.expm1(w[0] * oof_lgb w[1] * oof_cat) - train[price].values)) res minimize(loss, [0.5, 0.5], bounds[(0, 1), (0, 1)], constraints{type: eq, fun: lambda w: w.sum() - 1}) print(res.x)Stacking 再往上叠一层 meta model收益有但容易过拟合meta 特征必须是 oofout-of-fold预测不能是训练集自身的预测。常见的翻车写法是用同一批训练数据训练 base model再用它对训练集预测出 y_pred把这个 y_pred 当特征去训练第二层模型这等于把答案抄了一遍验证分会虚高到离谱。我用 Stacking 时base model 的预测全部由 K 折 oof 产生第二层模型只吃 oof 特征测试集预测则取各折模型预测的均值。5. 避坑实录二手车价格预测最容易翻车的 5 个环节5.1 现象线下验证分数很好提交后排名大幅下滑原因几乎都指向同一个地方验证集构造不严谨。特别是用了全量数据的 target encoding或者对 price 做 groupby 均值特征时没有限定在折内。模型在验证集上“偷看”了全局统计量相当于提前知道了答案。解决方法是回看所有涉及标签的统计特征一律改成 K 折内部计算测试集的映射才允许用全量均值。这条是表格竞赛里最常见的翻车点我的血泪经验是任何特征构造里出现了target或price字样都要先问自己一句“这个值在线上是否拿得到”。5.2 现象notRepairedDamage 列明明有缺失但isnull()查不到原因是数据源把缺失写成了字符串nanPandas 把它当作普通字符串读入整列变成 object dtype。用value_counts()能看到一个独立的nan类但你就是查不到 NaN。解决方式就是 2.2 里的map显式替换同时检查列名里类似nan、None、这类伪装缺失值。更稳妥的办法是在读入时给na_values传一个列表把所有伪装缺失值一次性映射成 NaN。5.3 现象log 空间训练的模型还原后价格整体偏低价格做 log1p 后模型学的是对数空间的均值还原到线性空间时低价格段误差被压缩高价格段误差被放大。如果你在验证时直接对np.expm1(oof)求均值会发现整体偏低。解决方法是训练目标保留 log1p但验证和调参始终在原始价格空间进行。另外可以统计各车型分组下的预测偏差在测试集上做分组校准这部分放到第六章展开。5.4 现象power 里的 0 值被当成正常特征线上模型对特殊车型失效0 功率大概率是缺失或特殊动力类型直接删除会让模型在预测时遇到没见过的组合就瞎猜。我的做法是填充组内中位数并保留power_is_zero标记同时给功率列做分箱让树模型能根据功率区间而不是绝对数值去切分。这个操作对 MAE 的影响不大但能显著降低极端情况下的单点误差最终排名往往就差在这几个点上。5.5 现象测试集和训练集的类别分布不同频率特征失效测试集里出现的 name 或 model 在训练集里一个都没有map之后是 NaN频率特征直接变成 0。解决方法是把频率编码和 label encoding 同时保留让模型在“没见过”的时候退回到 label 的分裂逻辑另一个办法是给稀有类别加一个is_rare标记当出现次数少于阈值比如 10时置 1。不要试图把稀有类别合并成“其他”类别那样会丢失太多信息树模型对稀有类别的切分本身就有一定容忍度。6. 竞赛后处理用伪标签和分组校正确把分数再压一截拿完 baseline 之后还有三个后处理技巧能再压一点 MAE。第一个是伪标签把测试集的高置信预测当作标签补进训练集。先训练一版模型对测试集预测再用交叉验证的方差判断置信度选方差最小的那批样本加入训练。操作上我会把测试集预测和训练集按 7:3 混合重新训练 100 轮左右Learning Rate 降到 0.02避免伪标签主导。注意伪标签的比例不要太高测试集噪声大的时候硬塞标签反而会让验证集分数变差。第二个是分组偏差校准。预测完成后按 brand 或 model 分组统计预测残差在测试集上减去该组的平均偏差。这个动作在验证集上做一次能看到稳定的收益因为某些车型的成交价存在系统性低估。但要注意如果分组里样本太少校准本身会引入噪声。我只对样本数大于 50 的分组做校准小于这个阈值的组直接跳过。第三个是验证策略再收紧。前面的 K 折是随机打乱这个赛题可以用 StratifiedKFold 按价格分箱分层让每一折的价格分布接近全局分布验证分数的波动会更小。划档边界我一般取价格分位数[0, 0.25, 0.5, 0.75, 1.0]让每折都覆盖高中低价位。这个操作不会改变模型上限但能让你的调参决策更可靠少做几次“觉得涨了实际没涨”的无效改动。我自己的习惯是每次改完特征或调完参都只记录变化方向和线下分数不做多组同时调。这个赛题我最初也用过堆特征的方式堆到一百多个特征时分数反而回落删掉后排在后二十位的噪音特征后才重新涨回来。特征数量不是越多越好验证策略稳定比单次分数高更重要。希望这些从数据清洗到后处理的完整路径能帮到你按这套流程复现出来的方案即使拿不到名次也会让你对表格回归赛事的理解扎实一截。本文还有配套的精品资源点击获取
返回列表