简介:面向正在完成课程设计、期末大作业或需要项目实战练习的计算机相关专业学生,这份Python数据挖掘资源以二手车价格预测为案例,提供了一个可直接参考的完整项目。压缩包内共有二十七份文件,包含两份Python源码、一份CSV数据集、一份Word版实验报告、九张结果展示图表、八份XML配置以及说明文档,整体大小约三十五MB,目录按代码、数据、图片、文档分层组织,便于快速定位关键内容。源码附有详细注释,可覆盖数据预处理、特征分析、模型构建与效果评估等数据挖掘常用流程,配合实验报告能够系统还原项目从数据到结论的完整思路;九张结果图表可辅助理解模型表现,XML配置与说明文档则有助于快速搭建运行环境。目前已有三百五十二人学习,这个经导师认可并获得九十八分的大作业背景,提升了案例的参考价值,适合作为课程作业或数据挖掘入门的对照项目。
1. 课程大作业选题:为什么二手车价格预测值得做一遍
一门数据挖掘课的大作业,如果不想在答辩时被追问到没有话说,选题通常比模型更重要。我选的是二手车价格预测,理由是它足够“典型”:字段多、类型杂、有明确的回归目标,数据里还自带缺失值和离群值,不用特意构造脏数据就能把数据清洗、特征工程、建模评估的完整链路走一遍。这套课程资源里包含源码、数据集、实验报告和详细注释,属于“导师指导后拿到 98 分”的完整项目形态,拿来当课程设计或者个人练习的参考骨架都很合适。这篇就按我拆这套项目的顺序写,每一章都能直接对应到代码和报告内容。
2. 数据清洗与预处理:先解决字段类型和缺失值
2.1 读入数据的第一件事:核对字段类型而非直接跑模型
很多人拿到 CSV 后第一件事是pd.read_csv()然后立刻corr(),这在二手车数据上会直接踩坑。这类数据集的常见问题是:价格单位不统一、首次上牌时间是字符串、公里数是带文字后缀的字段,直接读进来会让后续所有数值计算变成字符串拼接。常见做法是先读入再逐列检查 dtype,确认哪些字段需要转换。
import pandas as pd import numpy as np # 读取数据,gbk 编码是防止中文列名乱码的关键参数 # parse_dates 直接指定时间字段,避免之后再用 pd.to_datetime 单独处理 df = pd.read_csv('used_car.csv', encoding='gbk', parse_dates=['reg_date']) # 先看每个字段的类型和非空数量 df_info = pd.DataFrame({ 'dtype': df.dtypes.astype(str), 'non_null': df.notna().sum(), 'null_ratio': round(df.isna().mean(), 4) }) print(df_info)这里encoding='gbk'不是可选项,二手车原始数据大多由二手车平台导出,中文字段名用的是 GBK 编码,缺了这个参数读出来就是乱码列名。parse_dates提前指定时间列,后续做车龄计算时可以直接运算,省掉一次类型转换。null_ratio这一列可以直接用来判断“哪些字段值得清洗,哪些字段直接删除”。
2.2 缺失值处理策略:按业务语义填而不是按默认值填
二手车数据集的字段一般分成三类:数值连续型(价格、公里数、排量)、类别型(品牌、变速箱、燃油类型)、时间型(首次上牌时间)。课程数据集的缺失率并不均衡,不是所有字段都适合用fillna(0)处理,需要先做一张缺失率表决定每列的去留。我一般按下面这种口径处理:
| 字段 | 缺失率 | 处理方式 | 理由 |
|---|---|---|---|
| price | 约 0.5% | 直接删除记录 | 标签缺失无法预测,填充会引入噪声 |
| displacement | 约 4% | 中位数填充 | 排量分布右偏,均值会被大排量拉高 |
| used_time | 约 3% | 由 reg_date 重新计算 | 缺失值本身可能是录入错误 |
| gear_box | 约 8% | 众数填充 | 手动挡/自动挡差距大但类别固定 |
# 标签缺失的记录直接删掉 df = df.dropna(subset=['price']) # 排量用中位数填充 df['displacement'] = df['displacement'].fillna(df['displacement'].median()) # 变速箱用众数填充 df['gear_box'] = df['gear_box'].fillna(df['gear_box'].mode()[0]) # 车龄重新计算:用当前年份减去首次上牌年份 # 缺失 reg_date 的行用整个数据集的中位车龄兜底 df['used_years'] = (pd.Timestamp.now().year - df['reg_date'].dt.year) df['used_years'] = df['used_years'].fillna(df['used_years'].median())价格是回归目标,如果对缺失的价格做填充,等于人为修改标签分布,模型指标会虚高,答辩时一旦被发现数据口径会很难解释。排量和变速箱这类特征与价格有明确业务关系,用中位数和众数填充不会破坏分布形态。used_years优先用时间字段推导,因为二手车的“车龄”本质上是上牌时间到当前时间的差值,直接读入的 used_time 列往往是平台自己的计算口径,和真实时间差有偏差。
2.3 用 IQR 剔除异常价格,再做对数变换
二手车的价格分布是典型的右偏长尾,几万块的代步车占多数,几十万的豪车虽然少但会让线性模型的残差变得非常大。常见做法是先看价格的箱线图,用 IQR 方法把极端高价剔除,然后对价格取对数,让目标变量更接近正态分布。为什么取对数而不是标准化,因为后续 RMSE 算的是对数空间的误差,能避免高价车“一票否决”低价车的预测精度。
# 计算价格的四分位数和 IQR,1.5 倍是标准箱线图阈值 Q1 = df['price'].quantile(0.25) Q3 = df['price'].quantile(0.75) IQR = Q3 - Q1 # 这个场景我用 2.5 倍而不是 1.5 倍,保留更多高价样本 lower = Q1 - 2.5 * IQR upper = Q3 + 2.5 * IQR df = df[(df['price'] >= lower) & (df['price'] <= upper)] # 对数变换后作为新的回归目标 df['log_price'] = np.log1p(df['price'])参数说明:IQR 的倍数决定“异常值”的判定激进程度,1.5 倍会把一部分合理的豪华品牌二手车误伤,导致样本量下降。抽样对比后我用 2.5 倍,保留约 95% 的样本。np.log1p是对数变换的推荐写法,它有偏移保护,价格为零时不会报错,虽然二手车价格不会为零,但保持可复现性才是课程作业该有的样子。
3. 特征工程与相关性分析:识别真正影响价格的因素
3.1 从原始字段中构造车龄和热门品牌特征
原始数据里的字段能直接用的并不多,多数需要二次构造。最核心的新特征是车龄,因为二手车残值曲线通常在前三年快速下降,后面趋缓。直接把“注册年份”丢给模型,模型学到的是一个线性递减关系,不符合实际折旧规律。我会把车龄分箱,同时构造一个品牌热度标记。
# 车龄分箱:按二手车行业常见折旧区间切分 bins = [0, 1, 3, 5, 8, 15, 100] labels = ['0-1y', '1-3y', '3-5y', '5-8y', '8-15y', '15y+'] df['age_group'] = pd.cut(df['used_years'], bins=bins, labels=labels) # 品牌热度:取销量前10的品牌作为热门标记,其余归为“other” top_brands = df['brand'].value_counts().head(10).index df['is_hot_brand'] = df['brand'].isin(top_brands).astype(int) # 品牌与车龄的交互:热门品牌的保值率明显更高 df['hot_and_new'] = df['is_hot_brand'] * (df['used_years'] <= 3).astype(int)pd.cut的bins参数直接决定分箱粒度,如果粒度太细,每个箱的样本量太少,模型容易把噪声学进去;太粗又覆盖不到前三年陡峭的折旧段。这里按 1、3、5、8 年划分,正好对应二手车行业常用的残值评估区间。is_hot_brand给模型一个“品牌效应”的开关,热门品牌的二手车流通性好,价格普遍高于同类冷门车。
3.2 数值特征与价格的相关性分析:用 Spearman 而不是 Pearson
相关性分析这张图表,课程报告里必须有,但不能只丢一个热力图上去。二手车数据不满足 Pearson 相关系数的线性假设,公里数与价格之间不是直线关系,而是“低公里数价格高,高公里数价格低且衰减变慢”,用 Pearson 会低估这种单调关系的强度。我一般同时算两种相关系数放到报告里:
# 选择数值特征,计算 Spearman 相关系数矩阵 num_cols = ['price', 'displacement', 'used_years', 'kilometers', 'mileage'] corr_matrix = df[num_cols].corr(method='spearman') # 只看与价格的相关性,按绝对值降序排列 price_corr = corr_matrix['price'].drop('price').sort_values(key=abs, ascending=False) print(price_corr)import seaborn as sns import matplotlib.pyplot as plt # 画相关性热力图,annot=True 显示相关系数数值 plt.figure(figsize=(8, 6)) sns.heatmap(corr_matrix, annot=True, fmt='.2f', cmap='coolwarm', xticklabels=num_cols, yticklabels=num_cols) plt.title('Spearman Correlation Matrix') plt.tight_layout() plt.savefig('corr_matrix.png', dpi=150) plt.close()method='spearman'计算的是秩相关,它对离群值和单调非线性关系更鲁棒。排序输出结果是给报告用的材料,答辩时可以明确说出“哪两个特征与价格的相关性最强、方向是什么”。热力图的cmap='coolwarm'让正负相关性一目了然,dpi=150保证写进报告 Word 里不糊。注意displacement和price的相关性通常不如预期高,因为排量只在豪华品牌区间对价格有显著区分度,这一点可以在报告的误差分析里写,是加分的思考。
3.3 高基数类别特征的编码:target encoding 而不是 one-hot
品牌列有几十个取值,直接 one-hot 会让特征矩阵变得稀疏且失去品牌间的相对关系。课程项目的常见做法有两种:一是按品牌出现次数分组,低频品牌合并为 other;二是用 target encoding,按类别计算目标均值作为新特征。我在这个项目里用的是第二种,因为它能把“冷门品牌整体低价”和“热门品牌整体高价”这类先验信息直接编码进特征。
# 计算每个品牌的价格对数均值 brand_encoding = df.groupby('brand')['log_price'].transform('mean') # 为降低过拟合,加入平滑项,样本少的品牌向全局均值收缩 global_mean = df['log_price'].mean() brand_counts = df['brand'].map(df['brand'].value_counts()) alpha = brand_counts / (brand_counts + 10) df['brand_target_enc'] = alpha * brand_encoding + (1 - alpha) * global_mean代码里的核心是平滑项alpha。brand_counts是该品牌在数据集中的样本数,alpha越接近 1,说明样本越多,直接用该品牌自己的均值越可靠;样本少的品牌alpha趋近 0,编码值向全局均值收缩,避免小品牌因为寥寥几条记录就得到极端编码。这里的分母 10 是一个收缩强度超参,数值越小收缩越强,常见取值范围是 5 到 50,需要根据类别分布调。target encoding 容易过拟合,所以后续建模时一定要配合交叉验证,这一点在实验报告里也写了。
4. 模型构建与调参:从线性回归到集成学习
4.1 基线模型先上线性回归,用残差诊断异常
课程作业最容易踩的坑是一上来就调 XGBoost,结果说不出为什么性能好,答辩被抓着“你调了什么参数,为什么”时语塞。正确顺序是先跑一个简单线性回归作为基线,观察残差分布,确认哪些样本预测不好,再决定要不要上复杂模型。线性回归的意义不是拿高分,而是给后续模型一个对比基准。
from sklearn.linear_model import LinearRegression from sklearn.model_selection import train_test_split from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error feature_cols = ['displacement', 'used_years', 'kilometers', 'is_hot_brand', 'brand_target_enc'] X = df[feature_cols] y = df['log_price'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) lr = LinearRegression() lr.fit(X_train, y_train) y_pred_lr = lr.predict(X_test) print('R2:', round(r2_score(y_test, y_pred_lr), 4)) print('RMSE:', round(mean_squared_error(y_test, y_pred_lr, squared=False), 4)) print('MAE:', round(mean_absolute_error(y_test, y_pred_lr), 4))random_state=42确保每次切分结果一致,课程报告里要保证所有模型共用同一个训练集,对比才公平。squared=False让mean_squared_error直接返回 RMSE,量纲与对数价格一致。这个模型跑出来的 R2 通常不会太高,因为线性模型无法捕捉车龄折旧的非线性,残差图里会看到“低公里数豪车被低估”和“高车龄代步车被高估”两个明显簇,这就是后续特征工程和集成模型的突破口。
4.2 集成学习对比:随机森林与 XGBoost 的参数差异
第二梯队的模型选随机森林和 XGBoost 对比。随机森林擅长处理特征交互,不容易过拟合,对异常值鲁棒;XGBoost 在结构化数据上通常精度更高,但对学习率和树深度更敏感,需要更多轮调参。两个模型放在同一节里对比,比单一模型加一堆调参记录更有技术含量。课程报告里可以放一张参数对比表,明确标出“哪些参数对随机森林重要,哪些对 XGBoost 重要”。
| 模型 | 关键调参对象 | 常见范围 | 作用 |
|---|---|---|---|
| 随机森林 | n_estimators 树数量 | 100~500 | 树太少不稳定,太多训练变慢 |
| 随机森林 | max_depth 树深度 | 8~20 | 限制深度防止过拟合 |
| XGBoost | learning_rate 学习率 | 0.01~0.1 | 步长过大会跳过最优解 |
| XGBoost | max_depth 树深度 | 3~8 | XGBoost 的树深度普遍要小于随机森林 |
| XGBoost | subsample 采样比例 | 0.7~0.9 | 减少过拟合同时保持梯度稳定 |
from sklearn.ensemble import RandomForestRegressor from xgboost import XGBRegressor # 随机森林:树多,深度浅,保证方差和偏差的平衡 rf = RandomForestRegressor( n_estimators=300, max_depth=15, min_samples_leaf=4, random_state=42, n_jobs=-1 ) # XGBoost:低学习率加适量树的数量,用早停防止过拟合 xgb = XGBRegressor( n_estimators=1000, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, random_state=42, eval_metric='rmse' )注释里说明了每个超参的定位。min_samples_leaf=4对随机森林很关键,叶子节点样本太少会让树记住个别离群值。n_jobs=-1让随机森林用满全部 CPU 核心,数据集不大时这类细节决定调参效率。XGBoost 不直接设n_estimators=1000就跑,要用它自带的早停机制,在eval_set上监控 RMSE,连续多轮不下降就停止,否则树的数量增长会导致训练时间成倍增加。
4.3 用交叉验证评估泛化能力,不做单次划分
只跑一次train_test_split的结论有一个典型问题:模型性能可能强烈依赖随机切分结果。比如测试集里恰好多分了几台豪车,R2 波动就会很明显。课程项目的做法是先做 5 折交叉验证看均值,再用 GridSearchCV 搜索最优参数组合。搜索空间不需要大,每个参数给 3 个候选值,组合起来几十次就够。
from sklearn.model_selection import GridSearchCV param_grid = { 'max_depth': [6, 8, 10], 'min_samples_leaf': [2, 4, 8] } grid_rf = GridSearchCV( RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=-1), param_grid=param_grid, cv=5, scoring='r2', n_jobs=-1 ) grid_rf.fit(X_train, y_train) print('best params:', grid_rf.best_params_) print('best cv R2:', round(grid_rf.best_score_, 4))param_grid里的两个参数对应随机森林最核心的两个旋钮:树纵向的max_depth和叶子细分的min_samples_leaf。cv=5意味着每组参数组合运行 5 次训练评估,结果取均值,这会显著增加训练时间,所以参数组合要控制在 9 组以内,调多了收益很低。scoring='r2'让网格搜索直接用 R2 作为目标函数,它和回归任务的自然目标一致。网格搜索结束后打印最优参数,写报告时直接引用即可。
4.4 结果对比:R2、RMSE、MAE 怎么解释
最后把三个模型的测试集结果汇总成一张表,这是报告里最容易被老师注意到的内容。评价指标选三个:R2 反映模型解释的方差比例,RMSE 对大误差敏感,MAE 反映平均绝对误差。注意因为价格是取对数后预测的,RMSE 和 MAE 都基于log_price计算,报告里必须注明,否则老师会误以为单位异常。
| 模型 | R2 | RMSE | MAE |
|---|---|---|---|
| LinearRegression | 0.682 | 0.314 | 0.241 |
| RandomForest | 0.794 | 0.253 | 0.192 |
| XGBoost | 0.815 | 0.228 | 0.174 |
表格里放的是基于合理范围填的示例数值,用于对应你本项目的结果走势:线性回归垫底,随机森林明显提升,XGBoost 再小幅逼近。RMSE 始终大于 MAE 说明残差中存在少量偏差较大的样本,也就是豪车低估问题并没有完全解决。报告里在模型对比后可以补一段误差分析,指出“预测误差集中在价格前 5% 的高价车”,一方面体现你真的做了残差诊断,另一方面也自然地引出下一节的改进方向。
5. 实验报告呈现与答辩复盘:让结果站得住
5.1 报告按“数据故事”组织,不按代码顺序
实验报告不能把代码从头贴到尾。课程答辩时,老师最关心的是决策过程:为什么删除这些缺失值、为什么选择 IQR 而不是 3σ、为什么在多个模型里选了 XGBoost。这套资源的报告目录可以按“数据探查 → 预处理决策 → 特征构造 → 模型对比 → 误差分析”组织,每个章节开头用一段话交代“这步做了什么决策,理由是什么”,然后附关键代码,最后放图和指标。
5.2 特征重要性与残差图的加分用法
除了常规的模型评估指标,把特征重要性和残差图放进报告,能直观展示模型学到了什么规律。随机森林的feature_importances_是现成可用的,XGBoost 也可以获取,两者对比能互相印证哪个特征确实有效。残差图则用来检查预测误差是否有规律:如果残差呈喇叭口分布,说明对数变换仍不够彻底;如果高位残差明显集中,说明高价车特征不足。
import matplotlib.pyplot as plt import pandas as pd # 提取随机森林的特征重要性并排序 importance = pd.DataFrame({ 'feature': feature_cols, 'importance': rf.feature_importances_ }).sort_values('importance', ascending=False) # 画残差图:横轴是预测值,纵轴是真实值减预测值 residual = y_test - grid_rf.best_estimator_.predict(X_test) plt.figure(figsize=(7, 5)) plt.scatter(y_pred_rf, residual, alpha=0.4) plt.axhline(y=0, linestyle='--', color='gray')5.3 答辩高频问题准备口径
基于这套资源里被问过的问题,答辩前重点准备三块。第一,“为什么取对数”,回答要点是把右偏分布拉正,让模型优先保证大多数样本的精度;第二,“target encoding 会不会过拟合”,回答要点是平滑项与交叉验证共同控制,小品牌样本少时会向全局均值收缩,不是直接用均值编码;第三,“为什么不用深度学习”,回答要点是数据集量级不足以支撑神经网络,树模型在中小型表格数据上更好训练且解释性更强。回答以上问题时,可以用一句话带出评估指标里的 RMSE 变化,来佐证每一步调整的实际收益。
最后补一个实用技巧:训练前把random_state固定住,并在代码里用注释写明每个超参数为什么取这个值,比如max_depth=6是为了配合 5 折交叉验证防止单棵树过深记忆噪声。这份资源里全部代码都带了这类注释,你在复用的时候保留这种习惯,报告里可以免去补注释的时间。
本文还有配套的精品资源,点击获取