ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

二手车价格预测实战:数据清洗、特征工程与多模型融合源码解析

二手车价格预测实战:数据清洗、特征工程与多模型融合源码解析 简介面向机器学习与数据挖掘初学者及毕业设计学生这是一套二手车交易市场大数据挖掘项目包。项目覆盖数据缺失值预测、交易价格预测与成交周期挖掘三个核心任务采用多模型融合策略对比XGBoost、随机森林、GBDT、梯度提升回归等模型效果。压缩包共二十四个文件大小约16.88MB以Python源码、Jupyter Notebook、训练好的pkl模型为主另含可视化图片、项目配置与说明文档目录按问题编号排列两个Notebook分别处理问题一和问题二Python脚本承担缺失值预测与类别转换等任务。目前已有九十八人浏览学习。适合作为课程设计、期末大作业或本科毕业设计的完整参考读者可获取可运行代码、模型权重、数据样例与调优思路直接复现二手车价格预测和成交周期挖掘流程。1. 二手车大数据挖掘源码包值得研究但不是因为它自带一个能跑的模型基于机器学习和多模型融合的二手车交易市场大数据挖掘源码包多半出现在课程设计和毕设场景里解压后是一堆 csv、几个 notebook、train.py 和一份项目说明。它要解决的问题很直接给定车龄、里程、排量、变速箱、过户次数、城市预测一台二手车的成交价。这个价值对买家是估值参考对车商是收车依据对做数据挖掘的人是完整的清洗、特征、建模、融合、验证闭环。适合三类人赶课程设计或毕设的学生、想拿机器学习算法练综合项目的开发者、想评估二手车智能估价能不能落地的从业者。先说结论多模型融合不会让分数暴涨但会让边缘区间的预测稳很多这部分恰恰是源码包最容易漏掉的东西。2. 把交易记录洗到能喂给模型清洗、编码与时间划分解压这种源码包之后先别急着跑模型。凡是来自非官方渠道的二手车交易数据几乎都带着录入错误和平台口径差异。常见字段不外乎 car_id、brand、model_name、register_date、mileage、engine_capacity、gearbox、emission_standard、transfer_count、city、transaction_date、price拿到手先做一次完整体检。很多源码包自带的模型跑分低问题不在模型选型而在数据根本没洗干净。数据清洗的目标是让模型学到“车的价值”而不是学到“录入人员的习惯”。比如有些平台把事故车单独标记有些平台不标记同一批车源放在一起训练时模型会把事故车的低价归因到品牌或车龄上。所以清洗阶段宁可多砍不要留着脏样本让树模型硬学。2.1 清洗缺失值、重复值、异常值一步到位import pandas as pd import numpy as np df pd.read_csv(car_transactions.csv, parse_dates[register_date, transaction_date]) # 1) 去重同一台车同一天只保留最后一次成交记录 df df.drop_duplicates(subset[car_id, transaction_date], keeplast) # 2) 异常里程负数直接删里程为 0 但车龄大于 1 年视为录入异常 df df[df[mileage] 0] df df[~((df[mileage] 0) (df[car_age_years] 1))] # 3) 车龄校核成交日与上牌日之差必须落在 0 到 30 年之间 df[car_age_years] (df[transaction_date] - df[register_date]).dt.days / 365.25 df df[(df[car_age_years] 0) (df[car_age_years] 30)] # 4) 价格缺失缺失率低于 10% 直接删行高于 10% 用同品牌同车龄段中位数回填 price_missing_rate df[price].isna().mean() if price_missing_rate 0.1: df df.dropna(subset[price]) else: age_bins pd.cut(df[car_age_years], bins[0, 3, 6, 10, 30]) df[price] df.groupby([brand, age_bins])[price].transform( lambda x: x.fillna(x.median()) )这里的参数要解释清楚keeplast表示如果同一台车同一天有多条记录保留最后写入的那条因为后写入的更可能是最终成交状态car_age_years大于 30 年的样本基本是录入错误极少数真古董车属于业务异常值直接删掉对回归任务影响极小价格缺失率阈值 10% 是我常用的分界点低于它删除行不会改变整体分布高于它就必须用同品牌同车龄段的中位数兜底避免直接删行把高缺失品牌全部剔出训练集。2.2 标称字段怎么进模型品牌、车型、变速箱的编码策略品牌、车型、变速箱、排放标准都是文本型字段。LightGBM、XGBoost、CatBoost 这类树模型虽然声称“处理类别特征”但不代表可以直接把字符串丢进去。我的习惯是先做低频归并再做整数编码。# 品牌样本量小于 100 的合并成 OTHER避免低频品牌带偏树模型 brand_counts df[brand].value_counts() df[brand] df[brand].where(df[brand].map(brand_counts) 100, OTHER) df[brand_code] df[brand].astype(category).cat.codes # 变速箱先归并再独热 df[gearbox_group] df[gearbox].replace({ 手自一体: 自动, 双离合: 自动, 无级变速: 自动, 手动: 手动 }) df pd.get_dummies(df, columns[gearbox_group], prefixgb) # 排放标准文本转数字国二到国六分别映射 2-6 emission_map {国二: 2, 国三: 3, 国四: 4, 国五: 5, 国六: 6} df[emission_score] df[emission_standard].map(emission_map).fillna(0)品牌用astype(category).cat.codes而不是LabelEncoder是因为前者对未出现过的类别更宽松后面的交叉验证不会因为新品牌直接报错。变速箱归并成自动、手动两档是因为国内二手车市场里双离合、手自一体、无级变速在定价逻辑上都算“自动挡”拆太细会让小样本类别产生抖动。排放标准是典型的有序类别映射成 2 到 6 的数值保留顺序信息fillna(0)负责处理“未标注”这类缺失。2.3 按时间划分数据集防止把“未来”偷进训练集二手车交易数据最容易被忽视的坑是随机切分训练集和验证集。如果 1 月到 6 月的车和 7 月到 12 月的车同时出现在训练集里验证集分数会虚高因为模型见过同一时间段的宏观行情。换句话说训练集和测试集的时间窗口一旦重叠模型就在“开卷考试”。df df.sort_values(transaction_date) split_date df[transaction_date].quantile(0.8) train df[df[transaction_date] split_date] valid df[df[transaction_date] split_date] print(f训练集时间范围{train[transaction_date].min()} ~ {train[transaction_date].max()}) print(f验证集时间范围{valid[transaction_date].min()} ~ {valid[transaction_date].max()})quantile(0.8)表示按时间排序后取前 80% 作为训练集后 20% 作为验证集这是时间序列类回归任务里相对稳妥的划分。注意这里不要用random_state随机打乱因为二手车价格天然受淡旺季和车辆保值率波动影响只有严格按时间前后切割验证集分数才对未来有参考意义。如果你的源码包里自带随机划分脚本建议直接改成时间划分再重新跑一遍基线。3. 特征工程决定融合模型上限三个能直接抄的特征方案多模型融合能弥补的是“模型偏差”弥补不了“特征缺失”。二手车定价里最核心的信息就藏在那十几个原始字段里怎么把车龄、里程、排量、品牌、车型转成模型真正用得上的数值直接决定了融合模型的上限。特征工程不是玄学是我做大数据挖掘项目时耗时最长的一步通常比调参多花两到三倍时间。3.1 原始字段的加工顺序车龄、里程、排量先后手车龄和里程是二手车定价的骨架。车龄直接从 register_date 和 transaction_date 算成 float单位用年而不是天数避免数值跨度过大导致树模型切分不稳定。里程的分布通常右偏严重有一批一年跑五万公里的营运车也有一年只跑五千公里的代步车直接把原始里程塞进模型高里程那侧会被树模型过度敏感。我的习惯是先按车龄分段对里程做分组统计再对里程做对数变换。这样做的理由是同样 3 万公里对一台 2 年车龄的车和一台 10 年车龄的车含义完全不同单纯的全局变换无法体现这种差异。排量本身是高区分度字段但注意部分平台把 1.5T 和 1.5L 混着标处理前先看唯一值分布把带 T 的字符串拆出来单独做一个涡轮增压标记。3.2 两个高杠杆特征保值率与价格密度保值率是二手车定价业务里真正懂行的人会看的指标公式是成交价除以新车指导价。它衡量的不是绝对价格而是这台车在市场上的折旧速度。做特征时不需要对每台车算而是按品牌和车龄段聚合出中位保值率再回填到每一行。# 保值率没有新车指导价时用同品牌同车龄段的历史中位成交价代替 age_band pd.cut(df[car_age_years], bins[0, 1, 3, 5, 8, 30]) brand_age_median df.groupby([brand, age_band])[price].transform(median) df[brand_age_median_price] brand_age_median # 用字段缺失率决定向量有 guide_price 就优先用 if guide_price in df.columns and df[guide_price].notna().mean() 0.5: df[value_retention] df[price] / df[guide_price].replace(0, np.nan) else: df[value_retention] df[price] / (brand_age_median 1)第二个高杠杆特征叫价格密度。同一个车型最近 90 天成交笔数越多说明这个车源市场竞争越激烈买方砍价空间越大。直接用原始笔数会被热门车型带偏所以要取对数。df[deal_density] df.groupby(model_name)[car_id].transform(count) df[log_deal_density] np.log1p(df[deal_density])这两个特征的逻辑要分开说保值率捕捉的是品牌和车型的折旧曲线让模型学会“同品牌同年限保值率高的车应该卖得更贵”价格密度捕捉的是供给端竞争笔数越多越说明这车是大众流通款价格透明、溢价空间小。两个特征都来自历史交易统计注意只能基于训练集计算再映射到验证集否则就提前把验证集的信息写进了特征。3.3 分位数截断与 Log 变换数值特征的“后悔药”特征工程里最常翻车的操作是直接对原始数值做标准化或归一化。树模型不需要标准化但非常需要截断。里程、成交价、车龄这类字段尾部往往有极端大值LightGBM 对这类值做直方图分箱时会让极个别样本单独占一个箱白白增加切分点。# 分位数截断对里程和排量取 1% 到 99% 分位把极端尾部拉回正常分布 for col in [mileage, engine_capacity]: lo df[col].quantile(0.01) hi df[col].quantile(0.99) df[col] df[col].clip(lo, hi) # 对数变换成交价作为回归目标也做 log让预测误差从绝对误差变成近似相对误差 df[log_mileage] np.log1p(df[mileage]) df[log_price] np.log1p(df[price])quantile(0.01)和quantile(0.99)是我常用的截断区间比 0.05/0.95 更保守保住了尾部样本又不让极端值影响分箱。log1p比log多一个1是为了处理里程恰好为 0 的样本。成交价做 log 变换是最重要的一步直接把原始价格当回归目标时模型会把绝大部分拟合能力花在少数几十万的高价车上导致几万元的主流代步车预测误差反而很大log 之后误差近似变成百分比误差这个问题会被天然抹平。4. 多模型融合的原理与复现把 LightGBM、XGBoost、CatBoost 拧成一股绳多模型融合在二手车价格预测里不是炫技而是刚需。原因是单模型各有偏科LightGBM 对大规模样本训练快但对噪声敏感XGBoost 对缺失值处理强但容易过拟合CatBoost 对标称特征友好却对数值特征的尺度变化不敏感随机森林方差低但上限也低。把它们放在一起才有机会把各自的偏科互补掉。周志华《机器学习》里讲集成学习的那一章核心观点就是“好而不同”每个基模型要足够准同时彼此差异要足够大。4.1 为什么单模型跑不过融合偏差与方差的那笔账单棵决策树是高方差低偏差bagging 通过平均把方差压下来boosting 通过逐步拟合残差把偏差降下去代价是方差变大。把四个不同算法做融合本质上是在“偏差”和“方差”之间找一个更优的平衡点。更直白的说法是如果四个模型在大部分样本上预测都差不多但在不同样本上各有输赢融合后就能把各自赢的那部分留下来。判断一个多模型融合方案是否有价值先看单模型的验证集表现。如果单模型里最好的 LightGBM 跑出 MAE 是 0.9 万四个基模型融合后还是 0.9 万说明基模型之间差异不够融合没起作用。真正合理的期望是融合模型比最好的单模型小幅提升 3% 到 5%同时验证集上预测值的方差明显变小也就是高估的低估的极端样本都更少了。4.2 第一层基学习器的选型与参数基线第一层是融合的地基。选型上我坚持“两个 boosting 加一个 bagging 再加一个纯线性模型”的组合刻意拉开差异。LightGBM 和 XGBoost 虽然都是 boosting但实现细节不同可以同时保留CatBoost 对类别特征有 ordered boosting 机制和前面两者在特征处理上差异最大随机森林负责把单棵树的抖动平均掉。模型关键参数初始值说明LightGBMnum_leaves31控制树复杂度比 max_depth 更值得先调LightGBMlearning_rate0.05学习率配合 n_estimators 一起看XGBoostmax_depth6深度太深容易在稀疏特征上过拟合XGBoostsubsample / colsample_bytree0.8 / 0.8行列采样降低方差CatBoostdepth6对应树的深度与 iterations 配合CatBoostrandom_seed42保证复现RandomForestn_estimators600树数量越大越稳但训练越慢from sklearn.ensemble import RandomForestRegressor import lightgbm as lgb import xgboost as xgb from catboost import CatBoostRegressor feature_cols [ car_age_years, log_mileage, engine_capacity, emission_score, brand_code, log_deal_density, value_retention, gb_auto, gb_manual ] X df[feature_cols].values y np.log1p(df[price].values) # 与 3.3 的 log_price 保持一致 base_models { lgb: lgb.LGBMRegressor( n_estimators1200, learning_rate0.05, num_leaves31, subsample0.8, colsample_bytree0.8, random_state42, verbose-1), xgb: xgb.XGBRegressor( n_estimators1200, learning_rate0.05, max_depth6, subsample0.8, colsample_bytree0.8, random_state42), cat: CatBoostRegressor( iterations1200, learning_rate0.05, depth6, random_seed42, verbose0), rf: RandomForestRegressor( n_estimators600, max_depth12, min_samples_leaf5, random_state42), }参数基线记住一个原则先让四个模型用相近的迭代轮数和学习率跑起来再单独调它们各自最敏感的那个参数。LightGBM 先调num_leavesXGBoost 先调max_depthCatBoost 先调depth随机森林先调max_depth。不要一上来就 GridSearch参数空间会爆炸而且二手车数据量级下提升未必比得上一次完善的特征工程。4.3 Stacking第二层为什么用 Ridge而不用又一个 GBDT第一层的四个模型各自输出一个预测值把它们拼成一列新的特征矩阵再用第二层模型拟合真实价格这就是 Stacking。第二层的任务不是继续挖掘原始特征而是学会“四个模型各说各话时我听谁的”。这里的关键是第一层的预测值传给第二层时必须用 out-of-fold 预测否则第二层学到的是第一层在训练集上的“死记硬背”。from sklearn.model_selection import KFold from sklearn.linear_model import Ridge def oof_predict(model, X, y, n_splits5): kf KFold(n_splitsn_splits, shuffleTrue, random_state42) oof np.zeros(len(X)) for train_idx, valid_idx in kf.split(X): model.fit(X[train_idx], y[train_idx]) oof[valid_idx] model.predict(X[valid_idx]) return oof # 第一层生成 out-of-fold 预测同时在新样本上做平均预测 meta_train np.column_stack([ oof_predict(base_models[lgb], X, y), oof_predict(base_models[xgb], X, y), oof_predict(base_models[cat], X, y), oof_predict(base_models[rf], X, y), ]) # 第二层Ridge 线性回归alpha1.0 控制正则强度 meta_model Ridge(alpha1.0) meta_model.fit(meta_train, y)n_splits5是 trade-off 的常见取值折数太少如 3out-of-fold 预测不够稳折数太多如 10训练时间翻倍但提升有限。shuffleTrue配合random_state42保证每次切分可复现。第二层选 Ridge 是因为四个基模型的预测值相关性很高几乎共线性Ridge 的 L2 正则会把冗余权重压小而不会像 LinearRegression 那样在共线性数据上产生权重震荡。千万不要在第二层继续叠一个 XGBoostOOF 预测里已经包含第一层各自的系统误差再用树模型去硬学等于把误差又学了一遍。新样本预测时要先把四个基模型的输出拼接好再喂给 Ridgedef blend_predict(models, meta_model, X_new): meta_features np.column_stack([ models[lgb].predict(X_new), models[xgb].predict(X_new), models[cat].predict(X_new), models[rf].predict(X_new), ]) return np.expm1(meta_model.predict(meta_features)) # 还原真实价格np.expm1是log1p的逆操作模型训练时目标做了 log 变换预测输出必须还原回真实价格单位万元。这一步漏掉预测结果整体会低一大截售价十几万的车可能只预测出几万这是最典型的翻车姿势之一后面避坑章节还会展开。5. 二手车多模型融合避坑排查现象、原因、处理办法做这种源码包项目最花时间的往往不是建模而是排错。下面五条都是我从解压到验收的真实经历里总结出来的坑每一条都按现象、原因、解决写清楚照着排查能省半天时间。5.1 zip 伪加密与解压路径乱码现象Windows 自带解压工具提示“文件损坏或密码错误”明明项目说明里没写密码。用 7-Zip 打开却能看到完整文件列表。原因这是 zip 的伪加密机制。文件目录区标记了加密位但数据区实际没有加密常见于压缩工具版本差异或打包时误操作。也有可能是文件名带了非 UTF-8 编码导致解压路径乱码。解决直接用 7-Zip 打开忽略加密标记拖出文件或用 Python 检查每个文件的加密标记位import zipfile with zipfile.ZipFile(project.zip) as zf: for info in zf.infolist(): # flag_bits 第 0 位为 1 表示加密标记伪加密时数据其实没加密 if info.flag_bits 0x1: print(f{info.filename}: 加密标记{info.flag_bits 0x1})flag_bits 0x1是判断伪加密的标准姿势。如果是真加密info.flag_bits还伴随其他位被置位需要密码如果只有第 0 位被置位基本可以确认是伪加密7-Zip 在解压时遇到这种情况会直接忽略加密标志。被乱码困扰时优先用 7-Zip 的“恢复到指定目录”功能它会按原始路径重建目录结构。5.2 里程表被调同车龄段里程分布右尾过长现象训练出来的模型对高里程车预测偏低而且同品牌同车龄段里个别里程奇高比如 10 年车龄 40 万公里的样本残差极大。原因调表是二手车行业的老问题数据里混入这类样本后模型会把里程的负效应学得过于激进另一种情况是营运车转私家车登记里程没重置里程本身真实但车况和同年限私家车完全不同。解决按车龄段分组做里程截断避免树模型被极端尾部带偏mileage_bands pd.cut(df[car_age_years], bins[0, 1, 3, 5, 8, 30]) df[mileage_clipped] df.groupby(mileage_bands)[mileage].transform( lambda s: s.clip(uppers.quantile(0.99)) )每个车龄段只取该段 99 分位作为上限超出上限的里程按上限处理而不是直接删掉样本。原因是被截断的样本还保留着品牌、车龄、排量等有效信息只是里程不可信截断比删除对模型更友好。另外如果数据里有transfer_count字段把变更次数大于 5 的样本单独打一个标记这类车往往是多次转手、车况存疑的低价车。5.3 排放标准文本直接入模树模型学到的是字符串乱序现象用LabelEncoder把排放标准从“国二”到“国六”编码成 0 到 4模型分数比不做这个字段更差。原因LabelEncoder赋的值不是按顺序给的可能“国四”是 1、“国二”是 0、“国六”是 2树模型在切分时读到的是这种无意义的整数排列反而引入了噪声。解决手动维护有序映射做成 2 到 6 的数值emission_map {国二: 2, 国三: 3, 国四: 4, 国五: 5, 国六: 6} df[emission_score] df[emission_standard].map(emission_map).fillna(0)fillna(0)把“未标注”和“新能源”这类特殊情况统一归到 0。如果业务里要区分新能源车额外加一列is_new_energy布尔特征不要让 emission_score0 承担双重语义。注意排放标准对价格的约束力是逐年变化的2019 年以后国六对旧排放标准车型的压制更强可以考虑把 transaction_date 的年份也放进特征让树模型自己学出这种交互效应。5.4 目标编码泄漏验证集分数虚高上线就崩现象用全量二手车交易数据计算“每个车型的平均成交价”放入特征验证集 R2 高达 0.97但换个时间段的数据预测就差得离谱。原因这是典型的目标编码泄漏。车型平均成交价本身就是由价格算出来的全量统计时已经包含了验证集的价格信息模型等于提前看到了答案。解决特征里所有由 price 聚合出来的统计量一律只用训练集计算再映射到验证集train_stats train.groupby(model_name)[price].median().rename(model_median_price) train train.join(train_stats, onmodel_name) valid valid.merge(train_stats.to_frame(), left_onmodel_name, right_indexTrue, howleft)这里的关键是“先切分、再统计”。如果不放心还可以在训练集内部用交叉验证生成统计量但这对二手车项目的增量不大时间划分之后训练集内部再做 5 折统计已经能把泄漏压到很低。记住一个判断标准任何特征的聚合计算只要分母包含验证集行就一定泄漏。5.5 模型文件版本错位joblib 和 pickle 不是一个东西现象项目说明里写“加载 model.pkl 即可预测”实际代码里却用joblib.load(model.pkl)有时报出ModuleNotFoundError或者AttributeError: LGBMRegressor object has no attribute best_iteration_。原因LightGBM 的不同版本之间模型结构有兼容性问题跨版本加载容易丢属性另外 sklearn 官方现在推荐用 joblib 而是 pickle两个格式混着用文件头都看不出差别。解决先确认模型文件的实际格式。joblib 文件开头有 n_bytes 记录pickle 文件开头有切分协议头直接用文件管理器看后缀最省事.pkl和.joblib都可能是同一个对象但加载方式要对上。加载后永远先跑一个最小样例import joblib # 先确认文件格式统一用 joblib 加载 model joblib.load(model.joblib) sample X_valid[:5] # 取 5 条验证集数据做冒烟测试 pred model.predict(sample) assert pred.shape (5,), prediction shape mismatchassert pred.shape (5,)这一行不是废话它能在 1 秒内暴露列顺序不一致、模型文件加载错误这类黑匣子问题。更保险的做法是如果源码包里同时有训练脚本和模型文件直接重新训练一遍因为真正值钱的是特征工程和融合逻辑而不是带着版本烙印的模型二进制。我自己做这种项目时从来不在交付物里放训练好的模型文件只放训练脚本和特征定义让接收方用自己的环境复现。6. 用残差和价格段指标验收融合模型能不能落地说了算验收融合模型的方法不能只看验证集 MAE 或 R2。二手车价格区间跨度从一万多到几十万一个全局 MAE 会掩盖低价车的系统性高估和高价车的极端误差。我的习惯是先看残差分布再按价格段看相对误差。残差等于真实价格减预测价格。如果残差在低价段全是负的说明模型系统性低估了低价车如果残差随价格升高明显增大说明模型拿捏不住高价车。对这种右偏分布我把残差除以真实价格得到相对残差再按价格段分组看中位数比全局 MAE 直观得多。价格段样本量建议重点看的指标5 万以下通常最大相对残差中位数容易系统性高估5 万到 15 万主流区间MARE这段是模型主力得分区15 万到 30 万中等绝对误差与相对残差一起看30 万以上通常较小合并相邻段样本太少单独看没有意义价格段划分不是拍脑袋5 万以下的车龄普遍超过 8 年排放标准、维修成本这些因素开始主导价格30 万以上样本通常稀少融合模型在这里大概率过拟合不要因为个别样本误差大就否定整个模型。最后可以用 SHAP 对基模型做一次快速可解释性对齐检查高价车的预测是否主要由品牌和车龄驱动里程是否出现在特征重要性的前三位import shap explainer shap.TreeExplainer(base_models[lgb]) shap_values explainer.shap_values(X_valid_sample) shap.summary_plot(shap_values, X_valid_sample, feature_namesfeature_cols)这里解释的是一个基模型而不是整个 Stacking因为 Ridge 融合层没有树结构无法直接做 SHAP。如果 SHAP 里里程的重要性排到第一而业务常识告诉你品牌和车龄才该是第一那就要回头查特征里是否混入了价格相关的统计量。我给自己定的验收铁律是融合模型验证集表现必须稳定优于最好的那个单模型否则就返回检查特征工程而不是继续堆模型。这份从解压数据到诊断残差的流程是我做这类二手车大数据挖掘项目踩了一圈坑之后沉淀下来的习惯希望帮到你。本文还有配套的精品资源点击获取
返回列表