☰
二手车价格预测实战:特征工程与树模型融合避坑指南
2026/10/10 18:58:59 网站建设 项目流程

简介:天池二手车价格预测竞赛高分项目源码包,适合机器学习入门者、竞赛新手及毕业设计/期末大作业场景,覆盖从数据探索、特征工程到模型调参与结果提交的完整竞赛链路。资源共16个文件,包含5个CSV数据文件(训练集、测试集、提交样例与最终submission结果)、2个IPython Notebook(分别对应LightGBM与XGBoost的建模全流程代码)、项目说明README,以及XML/IML等工程配置辅助文件,压缩包约32MB,数据与代码齐全,解压即可直接运行。已有829人学习。项目以两种梯度提升树算法为核心,对特征进行构造、清洗与筛选,并结合网格搜索/交叉验证等调参手段优化预测精度;Notebook中保留分步骤实验记录,便于逐行对照理解参数变化与效果差异。读者可依据源码与说明快速复现高分方案,也能将特征工程和集成模型思路迁移到同类价格回归任务,非常适合作为竞赛实践、毕业设计或期末大作业的参考资料。

1. 二手车价格预测赛题:先搞懂特征和MAE,再谈跑模型

天池竞赛里的二手车价格预测,是典型的表格回归赛题:给你一批包含车辆注册日期、里程、功率、品牌车型和十余个匿名数值特征的数据,让你预测交易价格,官方用 MAE 打分。这个赛题适合练手,是因为数据规模在十万级、特征类型覆盖数值/类别/时间/匿名四类,刚好踩中大多数业务模型的常见场景。一份二手车价格预测的项目源码和项目说明里,值钱的部分基本就集中在这三件事:数据 EDA、特征工程、多模型融合。它适合第一次参加天池比赛的新手,也适合做过 yolov8 训练自己的数据集这类视觉项目、想补表格模型经验的人,读完照着复现一套稳定进前排的基线是完全可行的。

2. 数据集 EDA:先用 pandas 摸清特征脾气,别急着训练

拿到任何一份二手车价格预测的数据集,我习惯先花三十分钟做 EDA 而不是直接跑模型。原因是树模型跑基线很快,但特征理解不够的话,后面每个特征工程决定都是盲目的,返工成本远高于这半小时。这个赛题的数据列大致包含 SaleID、regDate、creatDate、name、model、brand、bodyType、fuelType、gearbox、power、kilometer、notRepairedDamage、regionCode、seller、offerType,以及 v_0 到 v_14 十五个匿名数值特征,标签是 price。搞清楚每一列的取值逻辑,后面所有特征工程才有依据。

2.1 先看 shape、dtypes 和缺失量:确定特征类型再动手

import pandas as pd train = pd.read_csv('train.csv') test = pd.read_csv('test.csv') print(train.shape, test.shape) # 确认训练/测试规模 print(train.columns.tolist()) # 列出全部列名 print(train.dtypes.value_counts()) # 看数值列和类别列的比例

这段代码解决三个问题:数据量级、列结构是否一致、特征类型分布。天池这套赛题里 train/test 的列结构基本一致,差在 test 没有 price。先确认 dtypes,是为了决定哪些列走 category 处理、哪些走数值填充,避免后面统一 fillna 把类别列填出错误语义。

missing = train.isnull().sum() missing = missing[missing > 0].sort_values(ascending=False) print(missing) cat_cols = train.select_dtypes('object').columns nuniq = train[cat_cols].nunique().sort_values(ascending=False) print(nuniq)

缺失量要分两类看:纯数值列如 power、kilometer 的缺失,以及类别列如 bodyType、fuelType、gearbox 的缺失。notRepairedDamage 里有大量字符串 '-',读进来会被当成 object 类型,它本质是二值特征,后面要单独映射成 0/1 而不是直接 fillna。nunique 输出里 name 和 regionCode 会达到数千甚至更高的基数,这个数字直接决定了不能用 one-hot,也决定了目标编码是必选项。跟目标检测数据集先核对标注框不同,表格数据集 EDA 的第一件事是核对类型和唯一值。

2.2 重点看 price、power、kilometer 三列的分布:异常值藏在描述统计里

import matplotlib.pyplot as plt print(train['price'].describe()) train['price'].hist(bins=100) plt.show()

price 是回归目标,它的分布决定了训练空间。做这个赛题时我会先打印 describe,再看直方图。价格通常是右偏的,头部存在几十万以上的高价样本,而 MAE 对这种高位样本非常敏感,预测偏一点就会把误差拉大。这也是后面要做 log 空间训练的直接原因,但现在只是观察,不改数据。

print(train['power'].describe()) print(train['kilometer'].describe()) print(train['notRepairedDamage'].value_counts(dropna=False))

power 的最小值如果是 0,说明存在缺省值被填成了 0,这类样本对模型是干扰项,但直接删又可能丢掉信息,常见做法是先统计占比再决定。kilometer 最小值 0 也要单独看——有的是真实新车、有的是异常录入,不能一刀切。notRepairedDamage 里的 '-' 要统计出来,映射成 0/1/缺失三种状态,而不是让 pandas 当成字符串丢给模型。这个阶段只做记录和标记,真正处理放到特征工程章节。

2.3 匿名特征 v_0 到 v_14:先当黑匣子用,别急着解读

v_cols = [c for c in train.columns if c.startswith('v_')] print(train[v_cols].isnull().sum().sum()) print(train[v_cols].min().min(), train[v_cols].max().max())

v_0 到 v_14 是脱敏后的匿名数值特征,EDA 阶段我只检查两件事:有没有缺失、有没有极端离群值。很多项目源码里会给这十五个特征做 PCA、聚类甚至可视化降维,但我个人经验是收益很不稳定。与其花时间猜它是什么业务含义,不如先原样丢给 LightGBM,让它用 feature importance 告诉我们哪些有效。这十五列本身就是强特征,在多数高分方案里重要性排名靠前,处理时只做缺失填充和类型压缩,不做手工变换。

3. 特征工程:把高基数类别和时间戳变成模型容易学的强特征

特征工程是二手车价格预测项目源码里最值得读的部分。纯数值特征和匿名特征其实没什么可做的,真正的提升来自高基数类别特征和时间特征的处理方式。这一章按我自己的处理顺序来:先对类别特征做目标编码,再从注册日期和发布时间里挖车龄,最后用一次快速训练做特征减法,顺便把内存压下来。

3.1 高基数类别特征:用平滑目标编码替代 one-hot

def te_base(train, test, col, target, alpha=5): """基础版平滑目标编码,alpha 控制平滑强度""" mean_all = target.mean() grouped = target.groupby(train[col]).agg(['mean', 'count']) encoding = (grouped['mean'] * grouped['count'] + mean_all * alpha) / (grouped['count'] + alpha) tr_enc = train[col].map(encoding).fillna(mean_all) te_enc = test[col].map(encoding).fillna(mean_all) return tr_enc, te_enc for col in ['brand', 'model', 'name', 'regionCode']: train[f'{col}_te'], test[f'{col}_te'] = te_base(train, test, col, train['price'], alpha=5)

这段代码的思路是:对 brand、model、name 这类高基数类别,统计每个类别下价格均值,再向全局均值做平滑收缩,类别样本量越少,编码值越被拉向全局均值。alpha 越大收缩越强,alpha=5 是赛题里比较稳的起点。低频类别不会因为只有几条样本就被赋予极端编码,这是它比直接 groupby 均值更可靠的原因。

注意,这个基础版是讲公式用的,不能直接对 train 用全量数据编码再 map 回 train,那叫目标编码泄露,会造成验证分数虚高,第 5 章会专门讲折内编码的正确写法。测试集用全量 train 算出的 encoding 字典直接映射,这是规范的。

3.2 从 regDate 和 creatDate 里挖车龄:时间差比单独年份更有用

for df in [train, test]: df['regDate'] = df['regDate'].fillna(0).astype(int).astype(str).str.zfill(8) df['regDate'] = pd.to_datetime(df['regDate'], format='%Y%m%d', errors='coerce') df['creatDate'] = pd.to_datetime(df['creatDate'], format='%Y%m%d', errors='coerce') df['age_days'] = (df['creatDate'] - df['regDate']).dt.days.abs() df['regYear'] = df['regDate'].dt.year

regDate 是注册日期,creatDate 是发布时间。二手车交易里发布通常晚于注册,所以两者相减是负数,取 abs 后就得到这辆车从注册到发布的天数,这个特征比单独用年份或月份更能刻画车龄。处理时先 fillna(0) 再转字符串补零,是为了让日期字段格式统一,解析失败的行会被 errors='coerce' 变成 NaT,后续 age_days 的 NaN 用中位数填充。regYear 单独提出来是因为年份是强周期性特征,模型直接学天数时会忽略跨年效应,补一列原始年份能让树模型多做一次切分。

3.3 用一次快速 LightGBM 做特征减法,顺便把内存压下来

import lightgbm as lgb from sklearn.model_selection import train_test_split features = [c for c in train.columns if c not in ['price', 'SaleID', 'regDate', 'creatDate']] X = train[features].fillna(-999) y = train['price'] X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size=0.2, random_state=42) model = lgb.LGBMRegressor(n_estimators=300, learning_rate=0.1, random_state=42) model.fit(X_tr, y_tr) imp = pd.Series(model.feature_importances_, index=X_tr.columns).sort_values(ascending=False) print(imp.head(30))

用默认参数跑三百轮,看 importance 排序,目的是删除排在最末尾、重要性接近 0 的列。这个赛题的基线特征通常有几十维,删掉无效列能减少过拟合,也让后续五折训练更快。fillna(-999) 是树模型通用的缺失处理,-999 对树来说只是一个独立分支位,不会被当成数值趋势。

for c in X.columns: if X[c].dtype == 'float64': X[c] = X[c].astype('float32')

这一步是降内存的常规操作:pandas 默认读入的浮点列是 float64,对决策树来说 float32 精度完全够。十万级数据量感受不明显,但如果后面要做五折加多模型融合,内存会从几十个特征叠加翻倍,提前压到 float32 能避免训练到一半内存溢出。

4. 模型训练与融合:五折交叉验证下的三个树模型

这个赛题的高分方案很少靠单模型,靠的是验证策略稳定和特征一致性。我一般会在特征工程做完之后,先固定一套五折交叉验证的框架,再往里填 LightGBM、XGBoost、CatBoost 三个模型,最后做加权融合。顺序不能反,五折框架是地基,模型是上层。

4.1 先写一个可复用的 KFold 训练函数

import numpy as np from sklearn.model_selection import KFold from sklearn.metrics import mean_absolute_error import lightgbm as lgb def lgb_kfold(X, y, params, n_folds=5): kf = KFold(n_splits=n_folds, shuffle=True, random_state=2024) oof = np.zeros(len(X)) models = [] for fold, (tr_idx, va_idx) in enumerate(kf.split(X)): dtr = lgb.Dataset(X.iloc[tr_idx], label=y.iloc[tr_idx]) dva = lgb.Dataset(X.iloc[va_idx], label=y.iloc[va_idx]) model = lgb.train( params, dtr, num_boost_round=5000, valid_sets=[dva], callbacks=[lgb.early_stopping(200), lgb.log_evaluation(200)] ) models.append(model) oof[va_idx] = model.predict(X.iloc[va_idx], num_iteration=model.best_iteration) fold_mae = mean_absolute_error(y.iloc[va_idx], oof[va_idx]) print(f'fold {fold} mae: {fold_mae:.4f}') print(f'oof mae: {mean_absolute_error(y, oof):.4f}') return oof, models

KFold 的 n_splits=5 是这个量级数据的主流选择,shuffle=True 且固定 random_state,保证每次实验可比。early_stopping(200) 的意思是两百轮验证集指标不提升就停止,防止模型在训练集上跑满 5000 轮导致过拟合。预测时用 best_iteration 而不是训练完毕的完整模型,这是拿到稳定 OOF 的关键。OOF 是 out-of-fold 预测,每折验证集拼起来的全量预测,它既能评估真实泛化能力,也能在融合阶段当训练数据。

4.2 LightGBM 基线参数:一组可以直接抄的配置

参数建议值说明
objectiveregression_l1直接优化 MAE,贴合赛题指标
learning_rate0.05调参前的固定起点,太大容易过拟合
num_leaves63数据量大可以给高一点,但也别超过 2^max_depth
max_depth-1让 num_leaves 决定复杂度,更灵活
feature_fraction0.8每棵树随机取 80% 特征,减少过拟合
bagging_fraction0.8每棵树随机取 80% 样本
bagging_freq1每一轮都做 bagging,不要隔几轮再做
min_child_samples20叶子节点最少样本数,压过拟合用
lambda_l10.1L1 正则,对稀疏特征友好
lambda_l21.0L2 正则,稳定数值特征

objective 用 regression_l1 而不是默认的 regression,因为赛题评价指标是 MAE,模型训练目标和评价指标一致才能让 early stopping 的决策可靠。learning_rate 从 0.05 起步,调参时最后再动它。特征比例和采样比例都设在 0.8,配合 min_child_samples=20,属于"偏保守"的基线配置,保证五折之间波动小。如果 OOF MAE 明显偏高,优先调 num_leaves 和 learning_rate,不要动 bagging 参数。

4.3 XGBoost 和 CatBoost 的差异点与加权融合

pred_lgb = np.mean([m.predict(X_test, num_iteration=m.best_iteration) for m in lgb_models], axis=0) pred_xgb = np.mean([m.predict(X_test) for m in xgb_models], axis=0) pred_cat = np.mean([m.predict(X_test) for m in cat_models], axis=0) pred_final = 0.4 * pred_lgb + 0.35 * pred_xgb + 0.25 * pred_cat

三个树模型里,XGBoost 对数值特征更敏感,适合把匿名特征 v_0 到 v_14 的价值榨出来;CatBoost 对类别特征有原生支持,但在这个赛题里因为已经做了目标编码,优势会被削弱,我一般只给它一个中等权重。融合权重不是玄学,先用 OOF 上三个模型的 MAE 做反比加权得到初始值,再微调。0.4、0.35、0.25 是常见的 LGB 优先分配,但必须在验证集上确认过再提交。只追求权重到小数点后两位就够了,纠结 0.05 的差异纯属浪费时间,真正拉开分数差距的是第 5 章那些坑有没有避开。

5. 避坑:二手车价格预测项目里最容易翻车的 5 个细节

这部分是我做这个赛题和后续类似项目时反复踩出来的血泪经验。每一条都从现象说起,再给原因和解决路径。翻车不可怕,可怕的是验证分数显示很好、提交后却完全对不上。

5.1 目标编码泄露:验证分数虚高的最隐蔽来源

现象:本地 OOF MAE 低到不敢信,比公开基线好一大截,但提交后榜单分数比本地高很多,甚至不如没做特征工程的版本。原因:用全量 train 的目标均值编码后,再 map 回 train 自身,等于每行样本都偷看了自己的标签,模型在验证集上"作弊"。解决:目标编码必须放进 KFold 内部,对每个训练折单独计算 encoding,映射到对应验证折,测试集则用全量 train 算出的字典映射。判断泄露有个快方法:训练完看 feature importance,如果 name_te 或 model_te 排第一且远超其他特征,基本就是泄露了。

5.2 里程为 0 和极端价格:别急着当脏数据删

现象:删掉 kilometer 为 0 和 price 高位的样本后,本地验证分数反而更稳,但提交分数明显变差。原因:0 公里可能是厂商指导价场景或未上路的新车,高位价格也是真实交易的一部分,MAE 对大额误差本来就更敏感,删除等于让模型从没见过这些模式。解决:保留这些样本,另外加一个 is_zero 标志列,把"是否 0 公里"变成显式信息,让模型自己决定怎么用。极端价格先看分位数,只有明显脱离交易逻辑的值(如价格为 1)才考虑替换为缺失。

5.3 类别特征做归一化、树模型配独热:白白浪费特征

现象:对 brand、model 做了 StandardScaler,LightGBM 分数没变化,XGBoost 反而变差。原因:树模型的切分只关心阈值,归一化不会改变切分位置;对类别特征做标准化还会破坏取值的可比性,稀疏 one-hot 被标准化后变成一堆负数小值,切分变得更碎。解决:树模型体系里数值列保持原始尺度即可,类别列要么目标编码、要么传给 LGBMRegressor 的 categorical_feature 参数。真需要归一化的场景是神经网络或 KNN,表格赛题里树模型是主角,别拿深度学习那套预处理硬套。

5.4 发布时间的分布差异:本地和线上分数对不上

现象:五折交叉验证稳定,但提交后分数波动大,同一套代码跑两次结果差很多。原因:train 和 test 的 creatDate 分布可能不一致,如果特征里包含了发布月份或星期,模型会把时间分段信息当成重要规律学进去,而测试集的时间分布变了,这个规律就失效。解决:做特征前先对比 train/test 的 creatDate 月份分布,分布差异明显就删除发布月份相关特征,或者把月份粗分箱成几段,降低模型对时间段的过度依赖。验证策略上可以用时间切分代替随机 KFold,按发布时间排序后取最后一段做验证。

5.5 name 和 model 独热把内存撑爆:高基数特征的正确打开方式

现象:训练几分钟后内存从 4G 涨到 30G,进程直接崩溃重启。原因:name 的唯一值可能有几千个,one-hot 编码后直接多出几千维稀疏列,内存和时间都失控。解决:高基数类别列不要碰 one-hot,用第 3 章的目标编码,或者退一步用频数编码。如果一定要保留原始类别信息,把 name 编码成数值 id 后交给 LightGBM 的 categorical_feature,让树模型自己做类别切分,内存压力会小很多。

6. 上分手感:log 空间训练和分组校准两个技巧

到这一步,基线已经稳定,剩下的提分来自对误差结构的细微调整。我先说一个习惯:每次改特征或改模型,只动一个变量,记录 OOF 变化,否则分数涨了也不知道是谁的功劳。这个赛题有两个低成本的上分技巧,几乎不需要再改模型结构。

6.1 在 log 空间训练,回到原空间评估

y_log = np.log1p(train['price']) # 训练时把 y_log 传给 lgb_kfold,预测得到 pred_log pred_price = np.expm1(pred_log)

价格右偏时,模型在 log 空间更容易拟合,尤其是高价位段的相对误差会被压缩。评估时仍然按原空间的 MAE 算,所以预测结果要 expm1 还原后再和真实价格比较。注意这个技巧只在目标右偏时有效,如果价格接近正态分布,log 变换带来的收益很小。

6.2 融合预测后按 brand 分组校准

bias = (pred_oof - y).groupby(train['brand']).mean() pred_final -= pred_final_brand.map(bias) * 0.5

不同品牌的价格区间差异很大,融合后的残差往往有品牌聚集性:某些品牌整体被高估,另一些被低估。按 brand 分组计算 OOF 残差均值,再从预测值里减去这个偏置乘以 0.5,相当于对系统偏差做一次温和修正。系数取 0.5 而不是 1,是为了防止对 OOF 过度拟合,留一半余量给测试集。

我每次复现二手车价格预测这类赛题,固定动作都是先跑一版 log 空间 LightGBM 拿到 OOF,确认 no leak,再做分组校准;如果发现特征重要性里单列异常高,第一反应永远是回去查编码有没有泄露,而不是庆祝分数。这套习惯帮我省过很多次无效调参,希望帮到你。

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

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

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

立即咨询