二手车价格预测实战:从数据清洗到模型调参的完整流程
2026/9/10 15:14:12 网站建设 项目流程

简介:这是一套面向数据挖掘课程大作业的二手车价格预测完整案例,适合正在学习Python数据分析、特征工程与回归建模的高校学生作为课程项目参考。压缩包内共27个文件,主体包含Python源码、csv格式二手车数据集、docx实验报告与png可视化图表,另有xml项目配置、README说明等辅助文件,整体体积约34.85MB,目录按代码、数据、文档分层组织,便于对照学习。核心源码均配有详细注释,覆盖数据清洗、特征处理、模型训练与价格预测的关键流程,实验报告则系统梳理了分析思路、实验过程与结论,可帮助读者快速迁移到同类预测任务。目前已有394人学习下载,适合需要完成课程大作业或入门数据挖掘实战的读者直接参考。

1. 二手车价格预测不是回归题,是数据清洗题

很多课程大作业把“二手车价格预测”当作一个模型比拼题,默认数据拿到了、特征够用了,剩下的交给 XGBoost。但真实跑下来你会发现:模型选 LinearRegression 还是 LightGBM,差距远没有“有没有处理价格长尾”和“有没有把品牌残值做对”来得大。二手车数据集的原始形态通常是爬虫抓下来的挂牌信息,车龄、里程、排量、变速箱、过户次数混在一起,价格字段里还有“面议”“一口价”和“5.8万”这种带单位的中文串。价格预测在这个场景里,先是一个数据清洗问题,再是一个特征工程问题,最后才轮到模型选型。这篇文章按课程大作业的标准流程,从数据预处理、特征构造、模型训练到实验报告组织方式,把一套可以照抄的 Python 实现拆开讲清楚,适合正在做这类题目或想拿真实数据集练手的人。

2. 数据加载与清洗:先把价格列变成可回归的数字

2.1 二手数据集的常见脏数据形态

课程大作业的数据集一般有两种来源:一是老师给的 CSV,列名可能是中文也可能是英文拼音;二是自己爬的某二手车平台挂牌页。后者的问题更典型:价格、里程、排量这些字段经常混着单位,比如价格: 12.8万里程: 3.2万公里排量: 1.5L。即便老师给的是整理过的数据,也大概率存在缺失值、异常值和一人多车导致的重复行。

开始写代码之前,先把文件读进来,快速看一眼 shape、dtypes 和前几行。这一步花不了两分钟,但能决定后面的清洗策略。

import pandas as pd import numpy as np df = pd.read_csv('car_data.csv', encoding='utf-8') print(df.shape) print(df.dtypes) print(df.head())

encoding参数在 Windows 下经常要换gbk,否则读进来就是乱码。shape 输出能告诉你行数和列数,dtypes 能看出哪几列被读成了 object——这些列就是需要清洗的重点。

2.2 价格字段的清洗与区间转换

价格列最常见的表现形式是两种:

  • 字符串数字加单位:"12.8万""9万""面议"
  • 价格区间:"10.5-12万",这种情况多出现在“车主报价”和“平台估价”并存的页面上

处理时我建议统一走“提取数字 → 区间取中值 → 剔除面议”这条路。区间取中值比取左端点更接近真实成交价,因为挂牌价偏高的车最终会往下谈,但区间本身已经框定了卖家的心理价格范围,中值是一个稳定的代理变量。

import re def parse_price(s): if pd.isna(s): return np.nan s = str(s).replace(',', '') # 数值范围:10.5-12万 nums = re.findall(r'\d+\.?\d*', s) if len(nums) == 0: return np.nan vals = [float(x) for x in nums] if '-' in s or '~' in s: return np.mean(vals) if '万' in s: return vals[0] * 10000 return vals[0] df['price'] = df['price'].apply(parse_price) df = df.dropna(subset=['price'])

正则\d+\.?\d*负责把字符串里的所有数值抽出来,字出现时意味着单位是万元,乘 10000 转成元。面议这类没有数字的字符串直接返回 NaN,最后统一丢弃。这步做完之后,用df['price'].describe()看一眼 min、max 和分位数,如果出现 0 元或者标价 300 万的异常车源,说明清洗还没到底。

2.3 缺失值与异常值的处理策略

价格列清洗完,接下来处理其余特征列。二手车数据集里缺失率高的通常是“过户次数”和“保养记录”这种非必填字段。处理方式按列的性质分三类:

  • 数值型且分布近似正态:用中位数填充,比均值更抗离群值
  • 分类型:用众数填充,或者单独加一个is_missing标记列
  • 缺失率超过 60% 的列:直接丢弃,补出来的值也没有统计意义

异常值的判定不要只盯标准差,用分位数更稳妥。price列如果 99% 分位数是 80 万,而某条数据是 200 万,这种点大概率是豪车或录入错误。用np.percentile计算上下限并把超出部分截断到边界值,比直接删除更能保留样本量。

from scipy import stats # 针对数值列的异常值截断 for col in ['mileage', 'engine_power', 'car_age']: q_low = df[col].quantile(0.01) q_high = df[col].quantile(0.99) df[col] = df[col].clip(q_low, q_high) # 对价格做 log1p 变换,压缩长尾 df['price_log'] = np.log1p(df['price'])

clip将 1% 和 99% 分位之外的极值拉到边界,log1p是价格预测任务里非常关键的一步。车价长尾极重,几万块的代步车和百万豪车在同一个量纲下,MSE 会被大价格样本牵着走。取对数之后,模型学的是“价格的对数”,预测完再np.expm1还原,误差分布更均匀。这一点在实验报告里写出来,老师会觉得你是真做过而不是只会调库。

3. 特征工程:从“字段”到“能解释价格”的变量

3.1 车龄与里程的交互特征

原始数据集里通常给的是“上牌日期”而不是“车龄”,需要把字符串转成 datetime,再用当前年份减去上牌年份得到car_age。里程和车龄单独看都只是线性影响价格,但两者结合能挖出更有解释力的信息:年均里程 = 总里程 / 车龄。年均里程能识别出网约车或营运车转私用的样本,这类车即使总里程不高,损耗也远大于家用车。

df['reg_date'] = pd.to_datetime(df['reg_date'], format='%Y%m%d', errors='coerce') df['car_age'] = 2024 - df['reg_date'].dt.year df['annual_mileage'] = df['mileage'] / (df['car_age'] + 1)

+1防止车龄为 0 的新车除零。annual_mileage大于 3 万公里的样本建议打上is_high_intensity标记,作为后续模型的一个二值特征。这类业务规则的植入,会让模型在新车和营运车之间自动学到不同的价格衰减曲线。

3.2 品牌残值率:从原始标签加工

品牌列是最容易直接 One-Hot 但也是最浪费的列。德系、日系、国产的保值率差异很大,直接用 One-Hot 会让模型把每个品牌当独立类别,学不到“日系整体保值”这种跨品牌规律。常见做法是构造一个“品牌残值率”特征:

  • 按品牌分组,计算mean(price) / max(price),其中max(price)取该品牌新车的均价
  • 没有新车价格时,可以退而求其次:按品牌 + 车龄 0~1 年的样本均价值作为基准
brand_price = df.groupby('brand')['price'].mean().sort_values() brand_top = brand_price.index[-5] # 取均值最高的品牌作为基准 df['brand_resale_ratio'] = df['brand'].map( df.groupby('brand')['price'].mean() / df[df['brand'] == brand_top]['price'].max() )

这段代码的思路是给每个品牌算一个“相对头部品牌的价格比例”,比例大的品牌说明整体价格坚挺。模型拿到这个数,比拿 50 个 One-Hot 稀疏列更容易学到梯度。品牌这个原始标签仍然保留在特征集里,残值率只是补充。

3.3 类别特征编码的分层策略

二手车数据集里非数值列通常是 brand、series、gearbox、color、fuel_type。这几列的性质差别很大,编码方式不能一刀切。

特征列类别基数推荐编码方式理由
gearbox2~3OrdinalEncoder手自一体可以赋 2,自动挡保值率高
fuel_type4~5One-Hot 或 Target Encoding油/电/混动对价格影响强且独立
brand30+Target Encoding避免 30 维稀疏哑变量
series300+不直接编码,聚类或按价格聚合类别太多,直接用会过拟合
color8~15Target Encoding白色、黑色保值率稳定,但不应单独建模

Target Encoding 的做法是用该类别下目标变量的均值替代类别本身。这里的目标变量是清洗后的price_log,注意要用交叉验证的方式计算均值,否则直接用全量数据算会产生标签泄漏。sklearn 里有现成的TargetEncodersklearn.preprocessing0.24 之后可用),自己写也很快。

from sklearn.model_selection import KFold from sklearn.preprocessing import TargetEncoder kf = KFold(n_splits=5, shuffle=True, random_state=42) te = TargetEncoder(smooth=10, target_type='continuous') # 这里用交叉验证防止过拟合 cv_preds = np.zeros(len(df)) for tr_idx, va_idx in kf.split(df): te.fit(df.iloc[tr_idx][['brand']], df.iloc[tr_idx]['price_log']) cv_preds[va_idx] = te.transform(df.iloc[va_idx][['brand']]).ravel() df['brand_te'] = cv_preds

smooth参数控制平滑程度,值越大,结果越往全局均值收缩,适合样本量少的类别。Target Encoding 的坑在于:验证集和测试集的类别均值必须严格由训练集算出,上面循环里每次都在训练折上fit,就是为了避免这一点。

4. 模型训练与调参:梯度提升树是默认答案,但线性模型不能丢

4.1 训练集划分与评估指标选用

划分数据时不要直接train_test_split,二手车数据里同品牌同年份的车高度相似,随机划分会让模型偷偷看到“已经见过的车”的邻居。建议按车型或品牌分组切分:GroupKFoldseries划分,保证同型号车的样本只出现在训练集或只出现在测试集,这样评估出来的泛化能力更真实。

评估指标用 RMSE 和 MAE 双指标。RMSE 对离群值敏感,能暴露预测极端值的问题;MAE 贴近业务理解,直接告诉老师“平均偏差多少钱”。注意最后交报告时的价格已经是还原后的实际元,不是 log 尺度,所以评估时要把预测结果expm1回去再算。

from sklearn.model_selection import GroupKFold from sklearn.metrics import mean_squared_error, mean_absolute_error import xgboost as xgb gkf = GroupKFold(n_splits=5) X = df.drop(columns=['price', 'price_log', 'brand', 'series']) y = df['price_log'] for tr_idx, va_idx in gkf.split(X, y, groups=df['series']): X_tr, X_va = X.iloc[tr_idx], X.iloc[va_idx] y_tr, y_va = y.iloc[tr_idx], y.iloc[va_idx] model = xgb.XGBRegressor( n_estimators=800, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.7, random_state=42 ) model.fit(X_tr, y_tr) pred_log = model.predict(X_va) pred = np.expm1(pred_log) y_true = np.expm1(y_va) rmse = mean_squared_error(y_true, pred, squared=False) mae = mean_absolute_error(y_true, pred) print(f'RMSE={rmse:.0f} MAE={mae:.0f}')

np.expm1(pred_log)np.log1p是一对互逆操作,展开后是exp(x) - 1。注意,一定要用同一个 sklearn 的KFold实例来拆分并同时服务于 Target Encoding 和模型评估,避免因为划分方式不同导致交叉验证结果不一致。

4.2 XGBoost 和 LightGBM 的参数对照

课程作业里一般要求至少对比两个模型,最常见的是“线性回归 vs XGBoost”或者“随机森林 vs LightGBM”。做对比时,模型本身谁强谁弱其实不重要,重要的是为什么某个模型在这个任务上更好。下面给一组 LightGBM 的参数模板,直接复制到代码里就能跑出不错的结果。

import lightgbm as lgb lgb_model = lgb.LGBMRegressor( objective='regression', n_estimators=1000, learning_rate=0.03, num_leaves=63, max_depth=7, min_child_samples=20, subsample=0.8, colsample_bytree=0.8, reg_alpha=0.1, reg_lambda=0.1, random_state=42, verbose=-1 )

num_leaves=63对应max_depth=6的树容量,LightGBM 用叶子生长策略,num_leavesmax_depth更影响模型复杂度。min_child_samples=20防止叶子节点样本太少导致过拟合。reg_alphareg_lambda是 L1/L2 正则,数据量只有几千条的时候建议都设成 0.1 左右,能显著压低验证集波动。

4.3 调参顺序和验证策略

网格搜索是大家都知道的词,但实际跑起来要注意顺序问题:不要把n_estimatorslearning_ratemax_depth一起塞进GridSearchCV。搜索空间呈指数增长,课程数据量小还能承受,几千个组合跑下来一个多小时,实验报告里写起来也不好看。

常用的顺序是:先把learning_rate固定成 0.1,n_estimators固定在 500,用GridSearchCV粗调max_depthmin_child_samples;锁定这两个之后,再把subsamplecolsample_bytreereg_alphareg_lambda用贝叶斯优化或随机搜索精调。LightGBM 的训练很快,所以这一步用RandomizedSearchCV可以一次多试几组组合,50 次迭代基本够用。

from sklearn.model_selection import RandomizedSearchCV from scipy.stats import uniform, randint param_dist = { 'num_leaves': randint(31, 127), 'max_depth': randint(5, 10), 'min_child_samples': randint(10, 50), 'subsample': uniform(0.6, 0.3), 'colsample_bytree': uniform(0.6, 0.3), 'reg_alpha': uniform(0, 0.5), 'reg_lambda': uniform(0, 0.5) } search = RandomizedSearchCV( lgb.LGBMRegressor(objective='regression', n_estimators=1000, learning_rate=0.03, random_state=42), param_distributions=param_dist, n_iter=50, cv=5, scoring='neg_mean_squared_error', random_state=42, n_jobs=-1 ) search.fit(X, y) print(search.best_params_)

scoring='neg_mean_squared_error'注意是负的,sklearn 默认最小化损失所以取负数。n_jobs=-1会跑满所有 CPU 核。搜索完成后把best_params_里的参数填入模型,再用整个训练集重新训练一次,最后在测试集上评估并记录日志。

5. 特征重要性与稳定性验证:报告里最加分的图表数据

5.1 用 SHAP 值验证特征解释方向

课程大作业的实验报告通常要求画特征重要性图,但重要性只告诉我们哪些特征重要,没告诉我们“影响方向”。SHAP 能补上这个缺口:每个特征对预测的贡献正负和大小一目了然。

import shap explainer = shap.TreeExplainer(model) shap_values = explainer.shap_values(X_va) shap.summary_plot(shap_values, X_va, max_display=15)

s summary_plot生成的是蜂群图,横轴是 SHAP 值,正值代表推高价格。这张图画出来之后,报告里可以明确写“car_age 的 SHAP 值随车龄增大显著为负,年均里程大于 3 万时交互效应加剧价格下跌”,比干贴一棵决策树更有说服力。SHAP 值的计算在树模型上是精确的,不需要蒙特卡洛采样,几万条样本也只需几十秒。

5.2 残差分析:发现定价偏差的系统性来源

模型训练完成后,应留出一折不参与调参,做独立的残差分析。把真实价格和预测价格都还原成元,然后按品牌分组看残差的均值。如果某个品牌的预测值系统性偏高或偏低,往往意味着品牌编码或者残值率特征没有完全捕捉它的特殊性。

residual = y_true - pred residual_df = pd.DataFrame({'brand': df.iloc[va_idx]['brand'].values, 'residual': residual}) brand_res = residual_df.groupby('brand')['residual'].agg(['mean', 'count']) print(brand_res.sort_values('mean').head(10))

这步的输出直接决定实验报告的“不足与改进”部分怎么写。比如国产某品牌残值均值是 -1.2 万,说明模型低估了它的价格——可能是置换补贴或者新车降价传导到二手车市场的时间差造成的。如果时间允许,可以再按 every 车系构造一个趋近年份的孤岛特征,下一版模型很有可能涨几个点的 R²。

6. 实验报告的组织技巧:把过程和结论写成可复现的技术文档

实验报告不是把代码粘贴一遍就完事,要求是“别人按照报告能复现出同样结果”。我见过的大部分课程报告,问题都出在缺中间检查点:只写了最终 RMSE,没有写每个特征加入前后的变化。这里给一个四段式的报告骨架:

  • 数据说明与预处理(原始数据规模、清洗规则、最终保留的样本量和特征数)
  • 特征工程(列出每个特征的构造逻辑,特别是 Target Encoding 的交叉验证细节)
  • 模型对比(至少三组实验:线性基线、XGBoost、LightGBM,记录训练时间和指标)
  • 结果分析与改进方向(SHAP 图 + 残差表 + 下一步能做的 2~3 个优化点)

报告里出现的每个数字都要能和代码对上,比如“清洗后剩余 5321 条样本,价格取对数后偏度从 3.1 降至 0.4”,这类具体数值比任何判断都更有说服力。特征重要性图用 SHAP 的max_display=15控制展示数量,超过 20 个特征时图会挤成一团。数据集的划分种子要固定,报告里写明random_state=42,这是可复现性的最低要求。

最后提醒一个容易被忽略的步骤:在提交代码前,重新从原始 CSV 跑一遍完整脚本,确认没有使用任何全局变量或内存中修改过的中间结果。课程作业的评分老师会直接运行python main.py,任何一步意外报错都会让前面的所有工作白费。把这个流程想成交付一个命令行接口,输入原始文件、输出模型指标和预测 CSV,这才是数据挖掘课程大作业最稳妥的收尾方式。

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

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

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

立即咨询