简介:这是一份面向数据挖掘课程作业的二手车价格预测完整案例,融合Python源码、数据集、实验报告与详细注释,适合高校学生、课程设计者及入门实践者参考。资源共27个文件,压缩包约34.85MB,核心内容包括2个Python脚本、1个CSV二手车交易数据集、1份DOCX实验报告,以及9张结果可视化PNG图、8个XML工程配置和1个Markdown说明文档,目录结构清晰,便于对照源码与文档学习,也方便二次修改。目前已有394人学习下载,可作为同类课程大作业的完整模板。通过这份资源,使用者可以快速掌握数据预处理、特征工程、模型训练与评估的完整流程,同时借助逐行注释与报告理解每一步设计思路,直接运行代码复现预测结果,有效节省从零搭建项目的时间。
1. 二手车价格预测大作业拆解:一份能直接跑通并写进报告的数据挖掘闭环
每到期末,数据挖掘课的教室里总会出现同一类求助:模型跑完 R² 是负的、实验报告凑不满三页、代码注释比天书还难懂。二手车价格预测是课程大作业的常客,因为它数据脏、特征杂、模型效果差距明显,恰好把数据挖掘的完整链路都逼了出来。你手上这套 Python 源码 + 数据集 + 实验报告 + 详细注释的资源包,关键不是“跑通”,而是把从数据分析到建模再到报告成稿的链路拆给读者看。这套流程适合正在做课程设计、需要交实验报告、或者想用真实数据集练一遍 Python 数据挖掘实战的人。照着做一遍,你能复现完整流程、看懂参数为什么这么设,并在答辩时把“为什么选这个模型”讲明白。
2. 拿到数据集先别建模:EDA 与数据清洗决定模型上限
解压资源包之后,第一件事不是立刻打开 notebook 跑模型,而是把数据文件找出来,弄清每个字段的含义和状态。大多数二手车数据的通病很一致:价格列带着逗号或货币符号,里程有录入错误,注册年份和采集年份混在一起。这些脏数据如果不处理,后续特征工程和模型对比的结果都是空中楼阁。下面按课程大作业的常见节奏,把数据体检到清洗的每一步写成可直接运行的代码。
2.1 字段体检:先看类型、缺失率和分布,再决定清洗策略
先用 pandas 把数据读进来,做一次全面体检。文件名以解压后实际的 csv 为准,我示范时统一写成car_data.csv。
import pandas as pd import numpy as np # 读入数据集,文件名视实际解压结果调整 df = pd.read_csv('car_data.csv') print('样本量与特征数:', df.shape) print('字段类型:\n', df.dtypes) print('缺失率:\n', df.isnull().mean().sort_values(ascending=False)) print('数值字段分布:\n', df.describe(percentiles=[.01, .25, .5, .75, .99]).T)这四行输出就是实验报告“数据探索”章节的全部素材。shape告诉你样本量和字段数,dtypes用来发现价格、里程这类本应是数值的列被读成了object,说明列里混入了逗号、字母或空字符串。isnull().mean()按列算缺失率,超过 30% 的字段建议直接删除而不是填充,因为填充大量假数据对树模型反而是噪声。describe里我特意加了.01和.99两个分位点,目的就是抓长尾——正常二手车里程的中位数可能只有 8 万公里,但少数样本能到 99 万公里,这些极端值往往不是真实交易,而是录入时多打了一位。
字段体检之后要记录两件事:一是价格序列严重右偏,均值远大于中位数;二是某些分类字段缺失率扎眼,比如排放标准可能有 20% 的空值。这两条在报告里要写清楚“看到了什么,所以下面决定怎么处理”。
2.2 缺失值与异常值处理:价格、里程、车龄的三块硬骨头
第一个硬骨头是价格列。如果读进来是object,先做字符串清洗再转数值。连续几个 cell 里都出现过的清洗手法如下:
# 清洗价格列:去掉逗号和货币符号,转成浮点数 df['price'] = df['price'].replace(',', '', regex=True).astype(float) # 价格对数变换:缓解右偏,同时保证后面模型的预测值非负 df['price_log'] = np.log1p(df['price']) # 里程极端值处理:超过 99.9% 分位数的样本视为录入错误 mile_upper = df['mileage'].quantile(0.999) print('里程上限:', mile_upper) df = df[df['mileage'] <= mile_upper] # 车龄计算:用数据采集年份减去注册年份 CAR_AGE_BASE = 2023 # 改成你这份数据集的采集年份 df['car_age'] = CAR_AGE_BASE - df['reg_year'] df = df[(df['car_age'] >= 0) & (df['car_age'] <= 30)]log1p是log(1+x),专门处理存在 0 值的正数序列,预测完再用expm1还原,两条互为逆运算。里程用quantile(0.999)切一刀而不是用max,因为真正的极端值只需去掉最顶部千分之一,硬编码一个“里程大于 50 万就删”容易误伤老款豪华车。车龄小于 0 说明注册年份晚于采集年份,属于录入错误,大于 30 年的车样本量极少且残差极大,删掉对整体稳定更有利。缺失值处理按字段性质来:数值字段用中位数填充,分类字段用众数填充,或者单独填一个“未知”并保留缺失标记,比用 0 填充更诚实。
2.3 分类变量编码:品牌、车系、排放标准怎么喂给模型
二手车数据的分类字段是重灾区。品牌有几十种,车系上百种,直接做 one-hot 编码会让特征矩阵膨胀到几千列,小数据集上训练慢且容易过拟合。常见做法是频次编码加目标编码。频次编码把类别替换成它在全样本中出现的次数,等于告诉模型“这个品牌是冷门还是热门”;目标编码用该类别价格均值作为特征,能捕捉品牌溢价这类业务信息。
# 频次编码:把品牌替换成出现次数 brand_freq = df['brand'].value_counts() df['brand_freq'] = df['brand'].map(brand_freq) # 目标编码:用该类别对数价格均值作为特征 brand_price_mean = df.groupby('brand')['price_log'].transform('mean') df['brand_target'] = brand_price_mean # one-hot 只保留低频范围内的高价值字段,比如变速箱类型 df = pd.concat([df, pd.get_dummies(df['gearbox'], prefix='gb')], axis=1)目标编码这一段很容易写出数据泄漏,如果先groupby算全量均值再拆分训练测试集,测试集的信息已经悄悄流进训练集,后面交叉验证分数会虚高。正确顺序是先train_test_split,然后只在训练集上groupby计算均值,再map到测试集。transform('mean')这行代码放在验证集上时务必重新计算。频次编码没有泄漏风险,因为它只依赖类别本身出现次数,不依赖标签,可以安全地在全量数据上计算。
3. 从线性回归到 XGBoost:二手车价格建模的基准与参数调整
数据清洗完成之后,特征工程和模型选型是一体的。二手车价格预测的实践结论很一致:线性模型能把业务规律讲清楚但精度有限,树模型能吃到非线性交互但容易过拟合,集成模型则是课程作业里性价比最高的选择。这一章把从基准到集成的完整路径走一遍,代码直接对应资源包里建模部分的注释。
3.1 特征工程收尾:车龄分箱、里程对数化、业务字段合并
把能用的字段整理成建模特征。里程继续做对数变换,因为二手车价格与里程的关系接近“前 5 万公里贬值最快、后面趋缓”,对数化后线性模型更容易拟合这种衰减。车龄本身是个连续值,但价格下跌存在台阶效应,比如 3 年质保期、6 年免检期,直接分箱让树模型省去自己找切分点。
# 里程对数化 df['mileage_log'] = np.log1p(df['mileage']) # 车龄分箱:0-3年、3-6年、6-10年、10-15年、15年以上 df['age_bin'] = pd.cut(df['car_age'], bins=[0, 3, 6, 10, 15, 30], labels=[0, 1, 2, 3, 4]) # 排放标准合并:样本少的老标准合并成一档 df['emission_cluster'] = df['emission'].map({ '国三': '老车', '国四': '老车', '国五': '主流', '国六': '新规' }) # 最终特征列表,按业务理解挑选而非全量塞入 feature_cols = ['mileage_log', 'car_age', 'age_bin', 'brand_freq', 'brand_target', 'displacement', 'gb_手动']特征列表的筛选原则是“每个特征都有业务理由”:mileage_log代表使用强度,brand_target代表品牌溢价,displacement代表排量带来的动力等级。没有业务理由的字段宁可不放。pd.cut的bins边界是经验的产物,如果你手里的数据价格台阶不明显,可以画图后重新调整,不必照抄。
3.2 先跑基准模型:线性回归与决策树给你“误差下限”
很多同学一上来就调 XGBoost,这是决策上的偷懒。基准模型的意义是告诉你数据的下限在哪:如果线性回归的 RMSE 已经不错,说明价格主要由少数特征线性决定,后面集成模型的提升空间有限;如果决策树默认参数就超过线性回归,说明特征交互很强,值得投入树模型。两步跑完,你才知道 XGBoost 的调参该往哪个方向用力。
from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.tree import DecisionTreeRegressor from sklearn.metrics import mean_squared_error, r2_score X = df[feature_cols] y = df['price_log'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) lr = LinearRegression().fit(X_train, y_train) dt = DecisionTreeRegressor(max_depth=8, min_samples_leaf=10, random_state=42).fit(X_train, y_train) for name, model in [('线性回归', lr), ('决策树', dt)]: pred = model.predict(X_test) rmse = mean_squared_error(y_test, pred, squared=False) print(name, 'RMSE(log域):', round(rmse, 4), 'R2:', round(r2_score(y_test, pred), 4))random_state=42固定下来,保证每次跑的结果可复现,这也是报告里必须写明的实验条件。决策树的max_depth=8、min_samples_leaf=10属于保守设置,防止它在几千条样本上长成一棵背答案的树。如果决策树在这个设置下就明显优于线性回归,证明价格与特征之间确实存在非线性关系,下一节的集成模型才有意义。注意我在 log 域上报 RMSE,这样误差近似理解为百分比误差,比如 RMSE=0.32 大约是 32% 左右的平均偏离,单位口径在报告里要写清楚。
3.3 集成模型参数:随机森林与 XGBoost 各有三处要调
基准模型跑完,集成模型按固定套路设置参数。随机森林的三处关键参数是n_estimators、max_depth、min_samples_leaf;XGBoost 的三处是learning_rate、subsample、colsample_bytree。其余参数先不动,等你看到验证集误差再决定是否增量调整。
from sklearn.ensemble import RandomForestRegressor from xgboost import XGBRegressor rf = RandomForestRegressor( n_estimators=300, max_depth=12, min_samples_leaf=4, max_features='sqrt', n_jobs=-1, random_state=42 ).fit(X_train, y_train) xgb = XGBRegressor( n_estimators=500, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0, random_state=42, eval_metric='rmse' ).fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)参数说明要落到“为什么”上:min_samples_leaf=4要求叶子节点至少 4 个样本,逼模型学大规律而不是抠个别样本;max_features='sqrt'让每棵树只看部分特征,增加树与树之间的差异,方差随之下降。XGBoost 的subsample=0.8是行采样,每棵树只用 80% 的样本,colsample_bytree=0.8是列采样,每棵树只用 80% 的特征,两个参数共同抑制过拟合。learning_rate=0.05配合n_estimators=500,相当于用更多棵小步长的树去逼近目标。跑完对比你会发现 XGBoost 往往比随机森林略好,但如果差距在 0.01 以内,报告里就诚实写“二者差异不显著”,这也是一个可讨论的结论。
4. 交叉验证与误差分析:把实验报告的“硬结论”支撑起来
建模跑完只是第一步,实验报告真正值钱的部分是“你的结论有多稳”。单次划分训练测试集得到的分值具有随机性,换一次random_state可能差出 0.05 的 R²。交叉验证和误差分析的目的就是消除这种随机性,让你在报告里敢写“本模型的 RMSE 稳定在 X 左右”而不是“某一次跑出了 Y”。
4.1 评估指标口径:RMSE、MAE、R² 怎么用才不误导
评估指标要在原始价格尺度上算,不能只报 log 域。log 域 RMSE 听起来像百分比误差,但答辩时老师更可能问“你这个模型平均差多少钱”,所以要把预测结果还原回元。
from sklearn.metrics import mean_squared_error, mean_absolute_error # 还原到原始价格尺度 pred_log = xgb.predict(X_test) pred_price = np.expm1(pred_log) true_price = np.expm1(y_test) rmse_price = mean_squared_error(true_price, pred_price, squared=False) mae_price = mean_absolute_error(true_price, pred_price) print('RMSE(元):', round(rmse_price, 2)) print('MAE(元):', round(mae_price, 2))RMSE 对大误差敏感,适合暴露模型在极端样本上的糟糕表现;MAE 反映平均偏差,更贴近业务上“每辆车平均估差多少钱”的直觉。报告中建议三个指标都列,再补一句“本模型 RMSE/MAE 的比值约为 1.6,说明误差中存在部分大偏差样本,而非均匀分布”,这句话直接引出下面的误差分析。
4.2 KFold 交叉验证:设置要与数据收集方式匹配
单次划分之外,用 KFold 交叉验证把稳定性测出来。
from sklearn.model_selection import KFold, cross_val_score kf = KFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(xgb, X, y, cv=kf, scoring='neg_root_mean_squared_error') print('每折RMSE(log域):', [-s for s in scores]) print('均值:', round(-scores.mean(), 4), '标准差:', round(scores.std(), 4))这里有个容易忽略的细节:scoring='neg_root_mean_squared_error'返回的是负值,因为 sklearn 的评分约定是“越大越好”,所以要取负才能得到真正的 RMSE。shuffle=True适用于二手车这种没有时间顺序的静态数据,但如果你的数据是分批次收集、后批次的年份整体更新,就要换成TimeSeriesSplit,否则未来数据会泄漏进训练集,交叉验证分数虚高。报告里写清楚“数据是截面数据,故采用随机 KFold”是加分项。
标准差是报告里最容易被忽略的一个数字:如果五折 RMSE 的标准差大于均值的 10%,说明模型在部分数据子集上表现不稳,通常是因为某些品牌或年份的样本太少。这时候不要急着调参,而是回到第 2 章检查这些样本是否被异常值处理误删。
4.3 误差分析:预测偏差大的样本有什么共同特征
交叉验证分数稳定之后,把预测误差最大的样本拉出来分析,这是报告里最有“数据挖掘感”的部分。
result = X_test.copy() result['true_price'] = true_price result['pred_price'] = pred_price result['error'] = pred_price - true_price result['error_ratio'] = result['error'] / result['true_price'] # 挑出偏差超过 30% 的样本 bad = result[result['error_ratio'].abs() > 0.3] print('偏差超30%样本数:', len(bad)) print(bad.groupby('brand_freq')['error_ratio'].agg(['mean', 'count']).head())error_ratio算的是相对误差,因为 100 万的车差 10 万和 5 万的车差 1 万,绝对误差相同但业务含义完全不同。跑完你会发现偏差大的样本集中在冷门品牌和老车上:冷门品牌训练样本太少,模型只能按品牌均值回归;车龄超过 15 年的车残值低且波动大,个别整备情况能差出一倍。这两条写进报告的“误差边界”一节,比空泛写“模型还有改进空间”有力得多,答辩老师就喜欢看你主动指出模型的失效范围。
4.4 实验报告结构:数据挖掘大作业的写法套路
资源包里那份实验报告,底层结构通常是固定的,按数据挖掘的标准流程组织即可。建议的章节与对应内容如下表,直接按这个骨架填充,报告不可能凑不满篇幅。
| 报告章节 | 写什么 | 对应处理 |
|---|---|---|
| 数据探索 | 数据集来源、字段含义、缺失率与分布图 | 第 2.1、2.2 节的输出 |
| 数据预处理 | 缺失值填充策略、异常值删除阈值、对数变换理由 | 第 2.2、2.3 节的代码 |
| 特征工程 | 频次编码与目标编码的选择、特征列表及业务含义 | 第 2.3、3.1 节的表格 |
| 模型构建与对比 | 至少 3 个模型 RMSE/MAE/R² 对比表 | 第 3.2、3.3、4.1 节的输出 |
| 模型评估与误差分析 | KFold 稳定性、误差分布、偏差大的样本特征 | 第 4.2、4.3 节的结论 |
| 结论与展望 | 一句话结论、当前边界、可扩展方向 | 误差分析的延伸 |
写作报告时要避免只贴代码截图,每个结论后补一句“为什么这么做”。例如“缺失率超过 30% 的字段直接删除而非填充,因为填充 30% 的假数据会引入噪声”,这句话就是报告里实打实的决策理由。
5. 避坑:二手车价格预测最容易翻车的 5 个现场
二手车价格预测的坑集中分布在数据泄漏、类别编码和目标变换三处。下面的每一条都是实操中反复出现的翻车现场,按“现象 → 原因 → 解决”记录。对照你手头资源包的代码和报告,发现命中同款问题时直接按方案改。
5.1 训练集 R² 很高、测试集 R² 是负的
现象:训练集 R² 能到 0.9,测试集一跑是负的,比“猜均值”还差。
原因:最典型的是 label encoding 把无序分类变量编码成 0、1、2、3,模型误以为这些数字之间有大小关系,在训练集上拟合出虚假规律。另一个常见情况是目标编码在划分训练测试集之前就全量计算,把测试集的价格均值泄漏给了训练集。
解决:分类变量一律用 one-hot、频次编码或目标编码,不用 label encoding。目标编码严格限制为先拆分、再在训练集上groupby计算、最后map到测试集。写完代码后检查一个细节:测试集样本里的brand_target是不是都能在训练集的映射表里找到,找不到就说明泄漏路径还没堵死。
5.2 预测价格出现负值
现象:模型跑出来的预测价格里有负数,$-$5000 元这种结果明显违反常识。
原因:直接在原始价格上做线性回归,线性模型没有非负约束,当特征空间边界上出现组合值时,预测值会被推到负区间。
解决:对价格做log1p变换后再建模,预测完用expm1还原,数学上保证了输出恒大于 0。这个方案的副产品是缓解了价格右偏,让 RMSE 对大价格样本的过度敏感也得到抑制。
5.3 交叉验证分数虚高,真实测试集上暴跌
现象:KFold 交叉验证的 R² 稳定在 0.88,但留出测试集一测只剩 0.72。
原因:预处理环节发生泄漏。常见泄漏路径包括:在拆分前用全量数据做标准化或目标编码、用全量数据的中位数填充缺失值、把测试集样本混入了value_counts统计。这些“看一眼全量数据再动手”的操作都会让交叉验证的每一折都被污染。
解决:把所有预处理放进Pipeline,让fit和transform严格分开。比如目标编码先fit再transform,填充器先fit再transform。实在嫌 Pipeline 麻烦,就在代码里保证所有mean、median、value_counts都只从训练集计算,测试集只执行映射。
5.4 换一次 random_state,分数差出 0.05
现象:同一个模型,random_state=42时 R² 是 0.86,改成 2023 变成 0.81,报告里不知道填哪个。
原因:数据集不大、模型方差高,单次划分结果受随机性影响大,只报一次分数是不严谨的。
解决:固定random_state=42并全篇统一,报告里写明“所有实验在固定随机种子下进行”。同时用第 4 章的 KFold 交叉验证取均值和标准差,这样单个随机种子带来的波动就被平均掉了,报告里写“五折 RMSE 均值 0.84,标准差 0.02”,可信度远高于单次数值。
5.5 XGBoost 和随机森林效果几乎一样,报告里不知怎么写对比
现象:两个模型 RMSE 只差 0.005,写在报告里显得集成模型调参毫无价值。
原因:二者都是树集成,如果特征工程阶段没有注入足够的信息量,比如目标编码没用、车龄没分箱,模型差异会被特征层面的“信息天花板”压制。
解决:先把第 3 章的特征工程补齐再谈模型差异。如果特征完整后差距仍然很小,报告就如实写“随机森林与 XGBoost 在本数据上表现接近,XGBoost 仅在极端样本上略有优势”,并补一张二者误差分布的对比图。承认模型相似本身就是一个经得起追问的数据挖掘结论,硬编一个假优势反而容易被答辩老师问穿。
6. 从“能跑”到“能答辩”:特征重要性与预测可视化
模型分数再高,答辩时老师更爱问“模型凭什么给这辆车估这个价”。特征重要性、SHAP 解释图和预测散点图就是回答这个问题的可视化素材,资源包的报告里如果这部分偏弱,可以按下面的方式补齐。
6.1 用特征重要性与 SHAP 解释模型决策
import shap import matplotlib.pyplot as plt xgb.fit(X_train, y_train) # 特征重要性横向条形图 imp = pd.Series(xgb.feature_importances_, index=feature_cols).sort_values() imp.plot.barh() plt.tight_layout() plt.savefig('feature_importance.png', dpi=150) # SHAP 摘要图:解释每个特征如何推高或拉低价格 explainer = shap.TreeExplainer(xgb) shap_values = explainer.shap_values(X_test) shap.summary_plot(shap_values, X_test, feature_names=feature_cols)特征重要性只告诉“哪些特征重要”,SHAP 能进一步告诉“特征值变大时价格被推高还是拉低”。比如车龄的 SHAP 点图里,蓝点聚集在负区间、红点聚集在正区间,就说明年轻车推高价格、老年车拉低价格,这正好对应第 3 章车龄分箱的业务判断。训练好的 xgb 树模型可以直接用TreeExplainer,不需要额外安装依赖。
6.2 预测与实际价格散点图:几处必须改的默认样式
fig, ax = plt.subplots(figsize=(6, 6)) ax.scatter(true_price, pred_price, s=8, alpha=0.4) max_price = max(true_price.max(), pred_price.max()) ax.plot([0, max_price], [0, max_price], 'r--', linewidth=1) ax.set_xlabel('实际价格(元)') ax.set_ylabel('预测价格(元)') ax.set_title('预测值 vs 实际值') plt.tight_layout() plt.savefig('pred_vs_true.png', dpi=150)散点图里必须画那条y=x红线,样本点越贴近红线代表预测越准。注意对角线要按true_price和pred_price的最大值取上限,否则图像会被截断,看不到高价车的分布。低价区点子贴线、高价区点子发散,是二手车模型的常态,正好呼应第 4 章误差分析的结论。这三张图放进报告,答辩时的“模型解释”环节就有了实打实的论据。
我每次做完这类大作业都会留一个习惯:把每次实验的模型配置、RMSE 和绘图脚本存成单独的文件,命名带日期,并把最终跑出报告结果的随机种子单独写在报告开头。这样答辩被问到“这个数怎么复现”时,打开文件重跑一遍就是同一组数,远比现场调参靠谱。二手车价格预测的这套流程,从数据清洗到模型解释每一步都有成熟套路,照着做一遍,你对数据挖掘课程设计的理解会比只抄代码深很多。希望这篇笔记帮到你完成这次大作业。
本文还有配套的精品资源,点击获取