☰
二手车价格预测实战:特征工程与MAE优化全流程解析
2026/10/6 3:00:27 网站建设 项目流程

简介:阿里天池与Datawhale联合举办的二手车交易价格预测竞赛中,这份优胜奖方案提供了从特征工程、模型训练到结果融合的完整实现,覆盖数据预处理至最终预测输出全过程。面向具备Python编程与数据分析基础的高校学生、研究人员,可作为课程设计、毕业设计或学科竞赛的参考范例。压缩包共15个文件,以8个Python脚本为核心,覆盖lgb_model、nn_model、cab_model及stack+mix等建模模块,另含预测结果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 有 price,test 没有,这是赛题预设的。先看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", errors="coerce") 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 或 model,map之后是 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_splits=5, shuffle=True, random_state=42) 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_splits=5, shuffle=True, random_state=42) 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_round=3000, valid_sets=[dva], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(0)] ) oof[va_idx] = model.predict(X.iloc[va_idx], num_iteration=model.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 特征必须是 oof(out-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()查不到

原因是数据源把缺失写成了字符串"nan",Pandas 把它当作普通字符串读入,整列变成 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],让每折都覆盖高中低价位。这个操作不会改变模型上限,但能让你的调参决策更可靠,少做几次“觉得涨了实际没涨”的无效改动。

我自己的习惯是每次改完特征或调完参,都只记录变化方向和线下分数,不做多组同时调。这个赛题我最初也用过堆特征的方式,堆到一百多个特征时分数反而回落,删掉后排在后二十位的噪音特征后才重新涨回来。特征数量不是越多越好,验证策略稳定比单次分数高更重要。希望这些从数据清洗到后处理的完整路径能帮到你,按这套流程复现出来的方案,即使拿不到名次,也会让你对表格回归赛事的理解扎实一截。

本文还有配套的精品资源,点击获取

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

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

立即咨询