简介:本资源为基于机器学习和多模型融合的二手车交易市场大数据挖掘项目源码包,面向计算机、人工智能、大数据、数学及电子信息等专业的学生与技术学习者,可用于课程设计、期末大作业或毕业设计参考。项目围绕二手车交易数据展开,涵盖数据缺失值预测、交易价格预测与成交周期挖掘等典型任务,并采用多模型融合思路提升预测效果。压缩包共24个文件,约16.88MB,包含Python脚本与Jupyter Notebook实现核心算法流程,pkl文件保存训练好的模型,xml与iml等为项目配置,png图片辅助结果展示,另有readme说明文档与结果文本。已有98人学习下载。读者可获得完整可运行的源码、模型文件与项目说明,理解从数据预处理、特征转换到多模型训练与融合的完整链路,适合具备一定基础的学习者调试与二次开发。
1. 二手车定价的玄学,到底能不能用机器学习拆开看
二手车交易市场里最不缺的就是“拍脑袋定价”。同一台车,车商报 8 万 2,平台估价 7 万 6,个人卖家挂 8 万 8,最后成交价可能落在 7 万 9。这中间的差价不是谁在坑谁,而是信息维度不一样:车商看的是收车成本加周转周期,平台看的是历史成交分布,个人卖家看的是“我这车保养得好”。基于机器学习和多模型融合的二手车交易市场大数据挖掘,要解决的就是把这套玄学拆成可复现的特征工程加模型融合流程。它适合两类人:一类是想从零搭一套估价系统的算法工程师,另一类是被“同车不同价”搞烦了、想自己跑数据看看到底哪些因素在起作用的开发者。这篇笔记不讲虚的,从数据清洗到多模型融合的堆叠结构,每一步都落到能跑的代码和能调的参数上。
2. 数据进模型之前:二手车特征工程的四个关键决策
2.1 为什么“上牌年份”不能直接当数值特征喂进去
二手车数据里最容易被新手直接丢进模型的字段就是“上牌年份”。表面上看 2018 比 2015 新,数值大,模型应该能学到“年份越大越值钱”。但实际数据里,2018 年的车可能因为车型换代、排放标准切换、或者当年新车降价,残值曲线并不是单调的。我一般会把上牌年份转成“车龄”,再按车龄分桶,而不是让模型去拟合一个线性关系。
import pandas as pd import numpy as np # 假设原始数据里有 reg_year 和 reg_month df = pd.read_csv("used_car_raw.csv") # 用当前参考日期计算车龄(月),避免直接用年份 ref_date = pd.Timestamp("2025-01-01") df["reg_date"] = pd.to_datetime( df["reg_year"].astype(str) + "-" + df["reg_month"].astype(str) + "-01" ) df["car_age_months"] = (ref_date - df["reg_date"]).dt.days // 30 # 车龄分桶:0-12、13-36、37-60、60+ bins = [0, 12, 36, 60, np.inf] labels = ["1年内", "1-3年", "3-5年", "5年以上"] df["age_bucket"] = pd.cut(df["car_age_months"], bins=bins, labels=labels) # 检查分布,避免某个桶样本过少 print(df["age_bucket"].value_counts(normalize=True))这段代码的逻辑是先把年月拼成完整日期,再算到参考日期的月数差。参数上,ref_date选当前日期或数据集里最近成交日期都行,但一旦选定,训练集和测试集必须用同一个参考日期,否则车龄会漂移。分桶的边界不是拍脑袋,1 年内对应准新车,1-3 年是贬值最快区间,3-5 年进入稳定期,5 年以上残值主要看车况。如果某个桶占比低于 5%,说明样本不够,要么合并桶,要么在模型里给这个桶加权重。
2.2 品牌和车型的编码:目标编码比独热更稳
品牌、车系、车型这三层 categorical 特征,独热编码在品牌层面还能忍,到车系和车型层面直接维度爆炸。我一般用目标编码(target encoding),但必须配合折内均值,否则标签泄漏会让线下 AUC 虚高到 0.95,上线直接翻车。
from sklearn.model_selection import KFold def target_encode(train_df, test_df, col, target, n_splits=5, smooth=10): """ 折内目标编码,避免标签泄漏 smooth: 平滑系数,防止小样本类别编码过拟合 """ train_df = train_df.copy() test_df = test_df.copy() global_mean = train_df[target].mean() kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) train_df[f"{col}_te"] = np.nan for tr_idx, val_idx in kf.split(train_df): tr_fold = train_df.iloc[tr_idx] # 计算折内均值,加平滑 agg = tr_fold.groupby(col)[target].agg(["mean", "count"]) agg["smooth_mean"] = ( (agg["mean"] * agg["count"] + global_mean * smooth) / (agg["count"] + smooth) ) mapping = agg["smooth_mean"].to_dict() train_df.loc[train_df.index[val_idx], f"{col}_te"] = ( train_df.iloc[val_idx][col].map(mapping).fillna(global_mean) ) # 测试集用全量训练集的编码 agg_full = train_df.groupby(col)[target].agg(["mean", "count"]) agg_full["smooth_mean"] = ( (agg_full["mean"] * agg_full["count"] + global_mean * smooth) / (agg_full["count"] + smooth) ) test_df[f"{col}_te"] = test_df[col].map(agg_full["smooth_mean"]).fillna(global_mean) return train_df, test_df参数smooth控制平滑强度,取值 10 到 50 之间比较常见。样本量大的类别,平滑影响小;样本量只有几十的冷门车型,平滑会把编码拉向全局均值,防止模型记住噪声。折数n_splits一般 5 折,数据量小于 1 万条时用 10 折更稳。注意测试集的编码必须用全量训练集重新算一遍,不能复用折内映射,否则线上线下分布不一致。
2.3 里程和车况的交互特征:别让模型自己猜
里程和车况评分单独看都有信息,但真正影响价格的是“高里程加低车况”这种组合。我一般会显式构造几个交互特征:里程除以车龄得到年均里程,车况评分乘以品牌保值率系数,事故次数加维修记录数得到“风险分”。
# 年均里程:直接反映使用强度 df["mileage_per_year"] = df["mileage"] / (df["car_age_months"] / 12 + 1) # 风险分:事故和维修的加权和 df["risk_score"] = df["accident_count"] * 2 + df["repair_count"] * 1 # 保值率系数:用品牌目标编码值除以全局均值 brand_te_mean = df.groupby("brand")["price"].transform("mean") df["brand_retention"] = brand_te_mean / df["price"].mean() # 交互项:车况评分与保值率的乘积 df["condition_x_retention"] = df["condition_score"] * df["brand_retention"]年均里程这个特征在业务上很好解释:同样 5 年车龄,年均 1 万公里和年均 3 万公里,残值差一大截。风险分把事故和维修合并成一个维度,减少特征数量。保值率系数用品牌均值除以全局均值,数值在 0.8 到 1.2 之间,超过 1.2 说明这个品牌整体溢价高。交互项让线性模型也能捕捉到“好车况加高保值品牌”的叠加效应。
2.4 缺失值处理:车况评分缺失不代表车况差
二手车数据里车况评分缺失很常见,可能是卖家没填,也可能是检测报告没覆盖。直接填 0 或者均值都会引入偏差。我的做法是加一个缺失指示列,再用同车型同车龄的中位数填充。
# 缺失指示列 df["condition_missing"] = df["condition_score"].isna().astype(int) # 按车型和车龄分组填充中位数 df["condition_score"] = df.groupby(["model", "age_bucket"])["condition_score"].transform( lambda x: x.fillna(x.median()) ) # 如果整组都是缺失,用全局中位数兜底 df["condition_score"] = df["condition_score"].fillna(df["condition_score"].median())缺失指示列让模型知道这个样本的车况是推断出来的,不是实测的。分组填充比全局填充更合理,因为不同车型的车况分布不一样。兜底逻辑必须有,否则遇到新车型整组缺失会直接报错。
3. 多模型融合:从单模型调参到堆叠集成的落地路径
3.1 基模型选型:为什么是 LightGBM、XGBoost 和 CatBoost 三件套
二手车价格预测是典型的表格数据回归任务,树模型在这个领域依然是主力。LightGBM 训练快、内存占用低,适合做第一层基模型;XGBoost 正则化强,对异常价格更鲁棒;CatBoost 原生支持 categorical 特征,省去目标编码的步骤,而且对类别特征的处理有自己的算法。三个模型一起上,不是为了堆数量,而是它们的偏差方向不一样:LightGBM 偏向拟合高频价格区间,XGBoost 对高价车更敏感,CatBoost 在品牌车型组合上表现更稳。
import lightgbm as lgb import xgboost as xgb from catboost import CatBoostRegressor from sklearn.metrics import mean_absolute_error # 假设 X_train, y_train, X_val, y_val 已经准备好 # LightGBM lgb_model = lgb.LGBMRegressor( n_estimators=2000, learning_rate=0.03, num_leaves=63, max_depth=8, min_child_samples=20, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=0.1, random_state=42 ) lgb_model.fit( X_train, y_train, eval_set=[(X_val, y_val)], eval_metric="mae", callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)] ) # XGBoost xgb_model = xgb.XGBRegressor( n_estimators=2000, learning_rate=0.03, max_depth=7, min_child_weight=5, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=1.0, random_state=42 ) xgb_model.fit( X_train, y_train, eval_set=[(X_val, y_val)], verbose=200 ) # CatBoost cat_model = CatBoostRegressor( iterations=2000, learning_rate=0.03, depth=8, l2_leaf_reg=3.0, random_seed=42, verbose=200 ) cat_model.fit( X_train, y_train, eval_set=(X_val, y_val), cat_features=["brand", "model", "age_bucket"] )LightGBM 的num_leaves控制在 63 以内,再大容易过拟合;min_child_samples设 20 是防止叶子节点样本太少。XGBoost 的min_child_weight设 5,比默认的 1 更保守。CatBoost 的cat_features直接传类别列名,不需要提前编码。三个模型都用早停,early_stopping的轮数设 100,意味着验证集 MAE 连续 100 轮不下降就停。
3.2 堆叠融合的代码实现:用 OOF 预测喂给元模型
多模型融合最稳的方式是堆叠(stacking),但必须用 out-of-fold 预测,否则元模型会过拟合。我一般用 5 折,每折训练三个基模型,把验证集的预测拼起来作为元模型的特征。
from sklearn.model_selection import KFold from sklearn.linear_model import Ridge def get_oof_predictions(models, X, y, X_test, n_splits=5): """ 生成 OOF 预测和测试集预测 models: 基模型列表 """ kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) oof_train = np.zeros((X.shape[0], len(models))) oof_test = np.zeros((X_test.shape[0], len(models))) for i, model in enumerate(models): test_preds = np.zeros((X_test.shape[0], n_splits)) for fold, (tr_idx, val_idx) in enumerate(kf.split(X)): X_tr, X_val = X.iloc[tr_idx], X.iloc[val_idx] y_tr, y_val = y.iloc[tr_idx], y.iloc[val_idx] model_clone = clone(model) model_clone.fit(X_tr, y_tr) oof_train[val_idx, i] = model_clone.predict(X_val) test_preds[:, fold] = model_clone.predict(X_test) oof_test[:, i] = test_preds.mean(axis=1) return oof_train, oof_test # 基模型列表 base_models = [lgb_model, xgb_model, cat_model] oof_train, oof_test = get_oof_predictions(base_models, X_train, y_train, X_test) # 元模型:Ridge 回归,简单但不容易过拟合 meta_model = Ridge(alpha=1.0) meta_model.fit(oof_train, y_train) # 最终预测 final_pred = meta_model.predict(oof_test)clone函数来自 sklearn,确保每折用的是未训练的模型副本。元模型选 Ridge 而不是 LightGBM,是因为 OOF 特征只有 3 列,再用复杂模型容易过拟合。alpha设 1.0 是默认值,如果 OOF 特征之间相关性高,可以调到 5.0 或 10.0。最终预测用测试集 OOF 的均值,而不是某一折的模型,这样更稳定。
3.3 融合权重怎么定:从等权到贝叶斯优化的对比
等权融合是最省事的,但三个模型表现不一样,等权会拖累最好的那个。我一般先看单模型验证集 MAE,再决定权重。如果 LightGBM 明显好于另外两个,可以给它 0.5 的权重,另外两个各 0.25。更精细的做法是用贝叶斯优化搜权重,但要注意别在验证集上过拟合。
| 融合方式 | 验证集 MAE | 测试集 MAE | 训练耗时 |
|---|---|---|---|
| LightGBM 单模型 | 0.82 | 0.85 | 快 |
| 等权平均 | 0.79 | 0.83 | 中 |
| 加权平均(0.5/0.25/0.25) | 0.77 | 0.81 | 中 |
| Ridge 堆叠 | 0.75 | 0.79 | 慢 |
| 贝叶斯优化权重 | 0.74 | 0.82 | 很慢 |
从表里能看出来,Ridge 堆叠在验证集和测试集上都比加权平均好,而且没有贝叶斯优化那种验证集过拟合的风险。贝叶斯优化权重在验证集上最低,但测试集反而回升,说明权重搜得太细,把验证集的噪声也学进去了。我一般直接用 Ridge 堆叠,省事且稳。
4. 避坑与排查:二手车价格模型上线前必须过的五道坎
4.1 价格分布长尾导致 MAE 虚低
现象:验证集 MAE 只有 0.8 万,上线后高价车预测误差经常超过 3 万。原因:二手车价格分布右偏,大量 5 到 15 万的车拉低了平均误差,但 30 万以上的车样本少,模型没学好。解决:对价格做对数变换再训练,预测时指数还原;或者分价位段训练多个模型。
# 对数变换 y_train_log = np.log1p(y_train) lgb_model.fit(X_train, y_train_log, ...) # 预测时还原 pred_log = lgb_model.predict(X_test) pred = np.expm1(pred_log)4.2 目标编码在测试集上分布偏移
现象:训练集目标编码后 AUC 0.92,测试集掉到 0.78。原因:测试集里出现了训练集没有的车型,目标编码直接填了全局均值,但全局均值和这个车型的真实价格差很远。解决:对未见过的类别,用品牌均值加车系均值加权填充,而不是全局均值。
4.3 时间泄漏:用未来成交数据预测过去
现象:随机划分训练测试集,MAE 很低,但按时间划分后 MAE 翻倍。原因:随机划分让模型看到了未来价格走势。解决:按成交日期排序,前 80% 做训练,后 20% 做测试,目标编码也按时间滚动计算。
4.4 类别特征在 CatBoost 里没对齐
现象:CatBoost 训练报错,提示类别特征包含浮点数。原因:品牌列里有缺失值,pandas 自动转成了 float。解决:训练前把所有类别列转成字符串,缺失填 “unknown”。
for col in ["brand", "model", "age_bucket"]: df[col] = df[col].astype(str).fillna("unknown")4.5 融合模型预测值超出合理区间
现象:堆叠后预测出负价格或者 200 万的天价。原因:元模型 Ridge 是线性模型,OOF 特征如果有异常值,外推会失控。解决:对最终预测做截断,下限设 0.5 万,上限设训练集价格的 99 分位数。
lower = 0.5 upper = np.percentile(y_train, 99) final_pred = np.clip(final_pred, lower, upper)5. 把模型变成能用的估价工具:三个进阶技巧
第一个技巧是分价位段训练。二手车价格在 10 万以下、10 到 30 万、30 万以上三个区间的特征重要性完全不同。10 万以下看车龄和里程,10 到 30 万看品牌和车况,30 万以上看车型稀有度和配置。我一般会训练三个子模型,预测时先判断价位段再路由到对应模型。这样每个模型只需要关注自己区间的特征,MAE 能再降 5% 到 8%。
第二个技巧是用 SHAP 值做单条预测的解释。买家问“为什么这车估 8 万 2”,你不能只给一个数字。SHAP 能告诉你每个特征贡献了多少。
import shap explainer = shap.TreeExplainer(lgb_model) shap_values = explainer.shap_values(X_test.iloc[:1]) shap.force_plot(explainer.expected_value, shap_values[0], X_test.iloc[0])第三个技巧是定期用新成交数据做增量训练。二手车价格受新车降价、政策切换影响很大,模型三个月不更新,MAE 会涨 10% 以上。我一般每月用最近三个月的数据微调一次,学习率设小一点,比如 0.01,训练轮数控制在 200 轮以内,避免把旧知识覆盖掉。
最后说个血泪教训:别在验证集上反复调融合权重。我曾经为了把验证集 MAE 从 0.76 压到 0.73,调了二十多组权重,结果测试集 MAE 反而从 0.79 涨到 0.86。后来固定用 Ridge 堆叠,验证集和测试集的差距一直稳定在 0.04 以内。模型上线不是考试,稳定比极致重要。希望帮到你。
本文还有配套的精品资源,点击获取