简介:这是一份面向数据挖掘初学者与天池赛事参赛者的二手车价格预测完整项目代码包。围绕超过40万条交易记录、31列变量的赛题数据,代码覆盖从数据探索(EDA)、缺失值处理到特征工程,再到基于CatBoost与LightGBM的5折交叉验证建模调参、加权融合的全流程方案。资源共12个文件,以Python主脚本、CSV预测结果、TSV训练日志、PNG可视化图片、JSON配置与TensorBoard事件文件为主,压缩包仅143KB,轻量易用;其中used_car_prediction.py为主脚本,requirements.txt便于快速复现环境。已有71人浏览学习。通过该代码包可直接复现线上分数422的完整方案,并学习如何处理匿名特征、构建时间特征、应用平均数编码等特征工程技巧,以及多模型融合思路;代码结构清晰,目录分工明确,适合作为二手车价格回归任务的实战参考,也可迁移至其他销售估价场景。 买过二手车的人都知道,同样一款车,有人买贵了几千,有人卖亏了几千,价格区间飘忽不定。而站在开发者的角度看,二手车价格预测本质上是一个典型的回归问题:给定车辆的品牌、车龄、里程、排量、变速箱等结构化字段,预测一个尽可能接近真实成交价的数值。这个场景非常适合拿来练手机器学习项目,因为它数据字段清晰、业务规律性强、结果可量化验证,而且做完之后还能直接打包成一个可运行的预测工具,不是那种学完就忘的“玩具项目”。
这篇文章从一个完整项目代码的角度,讲讲我实际搭建二手车价格预测方案的全过程。包括为什么选择XGBoost而不是神经网络,特征工程里哪些细节对精度影响最大,训练脚本和预测脚本怎么组织,以及模型上线后那些容易翻车的边界情况。适合正在做机器学习课程设计、准备比赛、或者想入门结构化数据建模的读者参考,代码思路可以直接复用。
1. 整体思路:预测二手车价格,难点不在模型而在框架
1.1 为什么不用神经网络,直接选XGBoost
我刚开始做这个项目时,也纠结过要不要上深度学习。后来把数据拉出来看了一眼,发现训练集总共只有几千条样本,特征也以类别型为主——品牌、车系、变速箱类型、排放标准这些,根本不是图像或文本那种高维稀疏数据。在这个量级和数据结构下,神经网络不仅训练效率低,还很容易过拟合,调参成本远高于收益。
对比下来,XGBoost几乎是为这种场景量身定做的:它对缺失值有内置处理策略,不需要在预处理阶段做非常复杂的填充;它对特征尺度不敏感,数值类特征不用做标准化;训练速度快,几千条数据几秒钟就能跑完一轮交叉验证;而且树模型天然支持特征交互,不需要手动构造太多乘积项。实际跑下来,同样的训练集,XGBoost验证集的RMSE比一个三层的MLP低了大概8%~12%,这个差距在价格预测里已经非常可观了。
1.2 项目代码的整体架构
这个项目的代码组织我参考了工业界机器学习项目常用的结构,不复杂,但每个模块职责清晰。目录结构大概是这样的:
car_price_prediction/ ├── data/ │ ├── raw/ # 原始数据 │ └── processed/ # 清洗后的特征数据 ├── src/ │ ├── preprocess.py # 数据清洗与特征工程 │ ├── train.py # 模型训练、调参与评估 │ ├── predict.py # 加载模型执行单条预测 │ └── config.py # 所有可配置参数的集中管理 ├── models/ # 训练产出的模型文件 ├── requirements.txt └── README.md这样做的好处很明显:preprocess.py保证训练和预测阶段的特征处理逻辑完全一致,不会出现训练时做了某个操作、预测时忘掉的情况;config.py把所有参数集中管理,调参时不用翻遍每个文件改数值;models目录独立存放训练产物,后续如果要部署成API服务,直接加载这里的模型文件就行。
2. 数据准备与特征工程:预测准不准,七成看这里
2.1 数据集选择和字段理解
我用的数据集来自一个二手车交易平台的公开脱敏数据,包含车辆品牌、车系、上牌年份、表显里程、排量、变速箱类型、排放标准、车辆所在地,以及最终的成交价格。字段不算多,一共10列左右,但每列都直接影响定价逻辑。
这里要特别提醒的是,拿到数据后不要急着写代码,先花半小时理解每个字段的业务含义。比如“上牌年份”和“车龄”是两回事,模型真正关心的是车龄——距离当前年份越远,折旧越多。“表显里程”是二手车定价中权重最高的因素之一,但它在真实场景里可能存在调表风险,所以有些方案会引入“年均里程”来缓解这个问题,即里程除以车龄,年均里程异常低的车辆反而需要警惕。
2.2 特征工程三板斧:清洗、编码、衍生
第一步是清洗。价格字段如果出现0或者极端小值,基本是数据录入错误,直接过滤掉;里程字段如果为空,我用该品牌同车龄车辆的中位数填充,而不是全局中位数,这样更贴近真实情况。分类字段的空值用众数填充,如果众数比例很低,就直接填一个“未知”类别。
第二步是编码。这里有个经验之谈:树模型对于有序分类特征,比如排放标准(国四、国五、国六),直接用LabelEncoder编码成1、2、3是可行的,因为树模型做分裂时只关心相对顺序。而无序类别,比如车辆所在地,如果类别数在10个以内,OneHot编码没问题;超过10个,建议先做频次统计,把出现次数低于阈值(比如少于30次)的类别合并为“其他”,再OneHot,否则维度膨胀会让训练变慢,而且稀疏特征对树模型的分裂收益很小。
第三步是衍生特征。我最常用的几个:一是车龄,由上牌年份计算得出,比直接用年份更能体现时间衰减;二是年均里程,用来刻画车辆的“使用强度”;三是品牌和车龄的交叉特征,因为不同品牌的保值率差异巨大,同样是5年车龄,日系车和部分美系车的残值可能差出20%。这部分我用一列“品牌_车龄段”来表示,比如“丰田_3-5年”,让模型可以直接学到组合规律。
标签处理也值得说道。原始价格分布是右偏的,几万块的代步车占了大头,几十万的车数量很少。如果不做处理,模型会把损失重点放在大数值样本上,导致低价车预测误差偏大。我的做法是对价格做log1p变换,即 target = log(price + 1),训练完成后预测结果再使用 expm1 还原。这一步对RMSE的改善非常明显,实测能降低约10%的误差。
3. 模型训练与调参环节:跑通一份可直接复用的代码
3.1 训练集/验证集划分
划分数据时有个容易犯的错误:直接随机打乱。如果数据是按时间顺序采集的,随机打乱会让模型“偷看”到未来信息,也就是数据泄漏,评估结果虚高。二手车价格受市场行情影响很大,同样一辆车,2021年卖和2023年卖价格能差出不少,所以合理的做法是按时间切分,比如用前80%的数据做训练,后20%的数据做验证。
为了更稳健地评估模型表现,我在训练脚本里同时使用了KFold交叉验证,具体是5折。每一折都重新训练、重新评估,最后取平均值。这样比单次划分更可靠,不会因为某一次划分运气好或者差而误判模型效果。
评估指标我同时关注三个:
| 指标 | 计算公式 | 说明 |
|---|---|---|
| RMSE | sqrt(mean((y_true - y_pred)^2)) | 大误差惩罚更重,适合衡量整体偏差 |
| MAE | mean(abs(y_true - y_pred)) | 直观反映平均偏离金额 |
| MAPE | mean(abs((y_true - y_pred)/y_true)) | 反映相对误差,便于横向比较 |
二手车价格从3万到50万跨度很大,单看RMSE容易被贵车带偏,所以我实际调参时以MAE和MAPE为主要参考指标。
3.2 XGBoost关键参数怎么调
直接贴一段我在项目里实际用的训练核心代码,参数都是调试后的结果,可直接跑通:
import xgboost as xgb from sklearn.model_selection import KFold from sklearn.metrics import mean_squared_error, mean_absolute_error import numpy as np # X_train, y_train 已经过特征工程和log1p处理 params = { 'objective': 'reg:squarederror', 'eta': 0.05, 'max_depth': 5, 'min_child_weight': 3, 'subsample': 0.8, 'colsample_bytree': 0.8, 'reg_alpha': 0.1, 'reg_lambda': 2.0, 'eval_metric': 'mae', 'seed': 42 } kf = KFold(n_splits=5, shuffle=True, random_state=42) mae_scores = [] for train_idx, val_idx in kf.split(X_train): dtrain = xgb.DMatrix(X_train.iloc[train_idx], label=y_train.iloc[train_idx]) dval = xgb.DMatrix(X_train.iloc[val_idx], label=y_train.iloc[val_idx]) model = xgb.train( params, dtrain, num_boost_round=2000, evals=[(dval, 'val')], early_stopping_rounds=50, verbose_eval=False ) pred = model.predict(dval) pred_price = np.expm1(pred) true_price = np.expm1(y_train.iloc[val_idx].values) mae_scores.append(mean_absolute_error(true_price, pred_price)) print(f'验证集MAE均值: {np.mean(mae_scores):.2f} 元')调参顺序我遵循一个固定套路:先把学习率eta固定为一个较低的值,比如0.05,此时需要配合更多的轮数;然后调max_depth和min_child_weight,这两个参数控制树的复杂度,max_depth一般在4~7之间,min_child_weight在1~5之间,先粗调再细调;接着调subsample和colsample_bytree,用来防止过拟合;最后加正则化参数reg_alpha和reg_lambda,小幅试即可,不用追求极致的微小提升。
还有一个很关键的技巧是早停。num_boost_round设得大一点,比如2000,早停轮数50,让模型自己决定什么时候停止,避免手动猜树的数量。我在调试时发现,如果把树的数量固定为500而不是用早停,验证集MAE会比早停时高3%左右,可见这个细节不能省。
3.3 模型导出与预测脚本
模型训练完成后,需要导出和封装。我这里用pickle保存整个模型对象,同时保存一份特征列名,预测时用来校验输入数据的字段顺序。
# train.py 中训练完成后的保存逻辑 import pickle # 保存模型和特征列名 with open('../models/xgb_model.pkl', 'wb') as f: pickle.dump(model, f) with open('../models/feature_columns.pkl', 'wb') as f: pickle.dump(list(X_train.columns), f)predict.py是给使用者直接调用的入口,支持两种方式:传入单条车辆信息,输出预测价格;或者传入一个csv文件,批量输出预测结果。核心逻辑如下:
import pickle import pandas as pd with open('../models/xgb_model.pkl', 'rb') as f: model = pickle.load(f) with open('../models/feature_columns.pkl', 'rb') as f: feature_columns = pickle.load(f) def predict_single(car_info: dict): df = pd.DataFrame([car_info]) # 这里调用preprocess.py里的transform函数做同样的特征处理 df = preprocess_features(df) df = df[feature_columns] dmatrix = xgb.DMatrix(df) pred_log = model.predict(dmatrix) return float(np.expm1(pred_log)[0])使用者的输入只需要字段名和值,具体怎么清洗、编码、衍生,全部封装在preprocess_features里面,这样即使不懂机器学习的同事也能直接调用接口。
4. 评估结果与误差分析:哪些车容易翻车
4.1 指标解读和残差分布
在实测数据集上(约8000条训练样本),模型的验证集表现大致是:MAE约5200元,MAPE约9.5%,R²约0.92。这个结果对于二手车价格预测来说已经具备参考价值,但比指标数字更重要的是观察残差的分布规律。
我通常会画一张残差图,横轴是真实价格,纵轴是预测值减真实值。从这个项目的结果来看,残差基本围绕0对称分布,但在两个区域明显发散:一是3万元以下的低价车,残差波动达到±8000元;二是30万元以上的高价车,残差波动甚至到±5万元。这两个区间的样本量占比本来就小,模型从数据中能学到的规律自然弱一些。
4.2 高价位车预测偏差大的原因和对策
为什么越是贵的车越难预测?核心原因有两个。第一是样本量不足,30万以上的车在整体数据里可能只有不到5%,树模型在分裂时很难为这些稀疏样本构建足够精细的路径。第二是高价车的个性化因素太多,同一款豪车,选装配置、原厂质保、保养记录,甚至车身颜色的热门程度都会影响最终成交价,而这些信息在原始字段里根本没有。
针对这个问题,我在项目中尝试过三种对策。一是把价格标签做分位数变换,不只是log,而是用分位数变换映射到正态分布,能稍微提升高价区间的表现,但低价车误差会变大,整体收益不明显。二是对数据进行分层建模,把价格按阈值划分为经济型(10万以下)和豪华型(10万以上),分别训练两个模型,各模型专注自己区间的特征规律,实测高价车MAE降低了约15%,但工程复杂度增加。三是增加特征,比如引入车辆所属城市的消费水平分档、品牌保值率外部数据,这些对高价车的解释力很强,但需要额外找数据源。
就这个项目而言,我最终的选择是对全量数据训练一个XGBoost,同时保留分层模型作为可选方案。如果你后续要把它发展成商业产品,建议走分层路线,因为高价车的绝对误差金额大,即使相对比例一致,带给用户的感知也是“预测很不准”。
5. 项目代码工程化的几个细节
5.1 代码组织、环境管理和可复现性
这个项目看似只是一次性训练,但如果要把代码交给别人复现,或者过两个月自己回来看,工程化细节就很重要。我做了几件事:requirements.txt里锁定所有依赖库的版本号,xgboost、pandas、numpy、scikit-learn都精确到小版本;在config.py里集中管理随机种子、数据路径、模型参数;训练脚本实现一套“全量训练+测试集评估”的命令行入口,确保任何人都能通过python train.py --config=config.yaml跑通整个流程。
环境隔离方面,我用conda创建了独立环境,避免和系统Python环境搅在一起。版本管理用git,每次改完特征工程或者调参,都提交一次代码,这样如果效果变差,可以直接回到上一个可用版本。还有个实用的小建议:如果使用VSCode开发,可以用cloc命令行工具或者VSCode的“查找在文件中”功能统计项目代码行数,对评估一个项目的复杂度和写文档都有帮助,但只是参考指标,不必过分追求。
5.2 用AI工具辅助生成项目代码的体验
我在写这个项目初期,也尝试用Claude Code和Codex这类工具辅助生成代码骨架。说实话,AI在生成预处理模板、配置解析、批量预测脚本这类“模式化”代码上效率确实高,能省下不少敲键盘的时间。比如predict.py的入口框架、参数解析逻辑,基本是先生成再人工修改,半小时内就能定稿。
但这里有个必须要说的教训:AI生成的模型训练代码,尤其是涉及数据分割和特征处理的逻辑,一定要人工逐行审查。我遇到过它把验证集也做fit_transform的情况,这就是典型的数据泄漏,训练时指标好看,上线就露馅。AI工具可以用来起稿、重构和检查代码风格,但数据科学里“对业务的理解”这部分责任,必须由开发者自己扛起来。生成代码后,最好用一个小的真实数据子集跑通全流程,再上全量数据。
6. 常见问题与排查记录
问:模型训练完,预测出来的价格出现负数,怎么回事?
答:如果你的目标变量没有做log变换,而原始数据里有少量异常低值(比如1万元),线性模型或者树模型可能预测出负数。解决办法有两个:一是对标签做log1p变换,从根上保证预测结果恒为正;二是在predict.py里对输出加一个下限截断,比如低于5000元一律按5000元处理,虽然粗暴但有效。
问:特征工程的时候,OneHot编码之后训练集有200列,预测时只有180列,报错说维度不匹配,怎么办?
答:这是训练和预测特征不一致的经典问题。出现原因是预测数据里某些类别在训练数据里没有出现,或者训练时低频频次合并的处理没有同步到预测逻辑。解决办法是在preprocess.py里把包含OneHot编码器的对象整体保存下来,预测时直接调用transform方法,而不是手动去写编码逻辑。sklearn的OneHotEncoder支持handle_unknown='ignore'参数,遇到新类别自动忽略,不会报错。
问:XGBoost训练时提示DMatrix不能接受字符串列,怎么处理?
答:所有的分类特征在使用DMatrix之前必须完成数值编码。检查一下你的特征矩阵里是否还有object类型的列,用df.dtypes查一下,发现后先做LabelEncoder或OneHotEncoder处理。一个排查技巧是:在XGBoost训练前加一行断言,assert all(df.dtypes != 'object'),及早暴露问题而不是等到训练报错。
问:模型在验证集上表现还可以,但放到真实场景预测时误差很大,什么原因?
答:大概率是数据分布不一致。训练数据过期了,市场价格已经变化;或者用户提交的车辆信息里带有训练时没见过的改装、事故等标记信息。针对前者,建议定期用最新数据重新训练;针对后者,可以考虑在预测时对缺失或未知字段做保守处理,比如事故记录未知时按照“有小事故”来预测,因为保守估计不容易引起用户反感。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 预测价格整体偏高 | 训练数据里的价格标签未做时效校正 | 用近期数据重训或引入时间衰减权重 |
| 某个品牌误差特别大 | 该品牌样本量不足 | 合并相似品牌或使用分层模型 |
| 里程数对结果几乎无影响 | 里程字段缺失比例过高 | 用年均里程替代绝对里程,减少缺失影响 |
| 增加特征后效果反而变差 | 新特征噪声大或有数据泄漏 | 检查特征的时间可用性,必要时回退版本 |
回想这个项目从零到能跑通,再到能稳定输出预测结果,踩过最大的坑就是特征工程阶段对时间信息的处理顺序。第一次做的时候,我先对整个数据集做了标准化和填充,然后才按时间划分训练集和测试集,导致验证结果虚高,上线后效果打回原形。后来改成先划分、后处理,并把所有预处理逻辑封装成可复用函数,才解决了这个问题。这个经验也是我想重点告诉后来者的:做预测类项目,时刻问自己一句,“在当前时刻,手头真的能拿到这个特征吗?”如果答案是否定的,那这个特征就不该进模型。
本文还有配套的精品资源,点击获取