简介Python课程大作业场景下的二手车价格数据挖掘与预测项目完整覆盖数据读取、特征处理、模型训练到结果评估的实践流程适合高校期末大作业、课程设计或毕业设计参考也便于刚入门Python数据分析的新手借助细致注释理解代码逻辑。资源共27个文件压缩包约34.86MB主要包含py源码、csv数据集、docx设计报告、png图表与界面截图、xml配置及项目元数据等各模块按项目结构存放部署后即可快速运行。当前已有211人学习可作为高分组作业模板复用。相比同类资源其优势在于代码注释详细、测试数据齐全并附带完整设计报告与可视化展示能帮助读者理解二手车定价的核心影响因素同时节省从零搭建环境与撰写文档的时间适合直接参考或二次扩展。1. 二手车价格预测的课程大作业从这份源码工程开始动手二手车价格预测是Python课程大作业里最稳的选题之一数据好找、特征直观而且“数据挖掘预测”这两个词能在一份作业里把数据清洗、特征工程、模型训练、误差分析整条链路完整走一遍。这份工程交付的是三样东西一套能直接跑通的源码、一份逐段注释的代码讲解以及一份和实验结果对应的设计报告——你拿到手要做的是把自己手头的数据集替换进去跑通流程再照着注释把每一段逻辑讲明白。它适合两类人一类是刚学完 pandas 和 sklearn、第一次做完整建模项目的新手另一类是已经有自己数据集、想找个工程框架来复用特征处理与模型调参顺序的熟手。接下来按我自己做这类项目时的顺序讲不把建模过程当黑匣子。2. 数据清洗和探索先摸清手头这份二手车数据的底细2.1 字段取舍先看信息再看价格别急着写模型大多数二手车数据集都会有这样几个字段品牌、车型、上牌日期、行驶里程、排量、变速箱类型、过户次数以及最关键的成交价格。第一次跑这种工程的时候我习惯先打印出数据的整体信息而不是直接开始处理缺失值。因为字段读得对不对直接决定后面所有特征能不能对上号。import pandas as pd import numpy as np # 读入原始数据常见格式是 Excel 或 csv # 如果字段带中文表头保留原始列名方便后续对照 df pd.read_excel(car_data.xlsx) # 1) 看每列的字段类型和缺失数量 print(df.info()) # 2) 看数值列的分布描述重点关注价格、里程、排量 print(df.describe().T)逻辑说明df.info()能一次性暴露两个问题——字段类型是不是读对了、哪些列已经有缺失。二手车原始表里“上牌日期”经常被读成字符串后面没法直接做年份减法所以这里要先确认 dtype。df.describe().T把数值列的均值、标准差、分位数打印出来价格字段的 min 和 max 如果差距太大后面就得考虑对数变换。参数说明describe()默认输出 25%、50%、75% 分位数如果数据集特别大可以加参数percentiles[0.1, 0.5, 0.9]改看自己关心的分位点一般保持默认就行。看完这步我通常会列一张字段取舍表作为设计报告里“数据预处理”章节的开头。字段是否进模型处理方式品牌是类别编码见 3.2上牌日期是转车龄后转成车龄原列删除行驶里程是先处理缺失与异常值排量是数值归一化变速箱类型是0/1 编码广告文案、图片链接否直接删除字段取舍的判断标准就一条它是不是买卖双方议价时会主动问的信息。品牌、车龄、里程、排量、过户次数都会被问到而广告文案描述里有大量噪声删掉对模型更干净。2.2 缺失值处理先算缺失率再决定删列还是填值缺失率低的列老老实实填缺失率高的列直接删这两条原则听起来简单实际操作里很多人栽在“所有列都填中位数”这一步。有些列的缺失本身代表业务含义比如“过户次数为空”可能意味着车从未过户这种信号不能被填充抹掉。# 按列计算缺失率超过阈值的列直接删除 null_ratio df.isnull().mean() cols_to_drop null_ratio[null_ratio 0.3].index.tolist() df df.drop(columnscols_to_drop) # 数值列用中位数填充扛住离群值干扰 num_cols df.select_dtypes(include[np.number]).columns df[num_cols] df[num_cols].apply(lambda x: x.fillna(x.median())) # 类别列填充后额外保留一列“是否缺失”的标记 cat_cols df.select_dtypes(include[object]).columns for col in cat_cols: if df[col].isnull().any(): df[col _is_na] df[col].isnull().astype(int) df[col] df[col].fillna(未知)逻辑说明null_ratio 0.3是一个经验阈值缺失超过三成的列无论用均值还是中位数填都会扭曲真实分布不如直接放弃。类别列填充成“未知”同时保留标记列是为了让模型自己学习“这条样本缺失”是否会影响价格而不是强行伪造一个正常值。参数说明0.3 这个阈值不是死的如果你总共有 5000 行、某列缺失 1200 行但你明确知道它很重要比如排量可以单独降阈值到 0.2 再手工决定要不要删。中位数比均值更抗离群值价格数据里有大量高价样本用均值填充里程这类偏态字段会偏离中心。2.3 价格分布预测前先看长尾对数变换要果断二手车成交价格天然是右偏长尾的——大多数车聚集在几万到十几万区间少量豪车直接拉到几十万甚至上百万。直接把这种标签丢给线性模型模型会把大量权重压在少数高价车上常见表现是验证集整体误差不大但中低价位段的预测全部走偏。我第一次跑这种数据时验证集分数几乎被几条高价样本主导属于典型的翻车现场。import matplotlib.pyplot as plt import seaborn as sns # 价格原始分布右侧长尾非常明显 sns.histplot(df[price], bins50) plt.title(price raw) plt.yscale(log) # y 轴取 log让长尾看得更清楚 plt.show() # 建模用对数价格预测结束后再用 expm1 还原成真实价格 df[price_log] np.log1p(df[price]) sns.histplot(df[price_log], bins50) plt.title(price log1p) plt.show()逻辑说明np.log1p是对原始价格做log(价格1)好处是压缩长尾、让价格分布更接近正态线性模型和树模型都更容易拟合。注意训练和预测必须在同一个空间进行模型拟合的是price_log预测结果要先np.expm1还原成真实价格再去计算误差指标。参数说明bins50是直方图的分箱数数据量超过几千行时可以调到 100图形更细。plt.yscale(log)只影响可视化不会改数据本身。在最终验证时MAE、RMSE 都要在expm1还原后的价格上算否则数值小得没有业务含义。3. 特征工程把车龄、里程和品牌整理成能进模型的数值3.1 车龄计算与组合特征两条单列不如一条“年均里程”能打数据集里给的往往是“上牌年份”而不是直接的车龄年份是绝对时间要转成相对变量才算建模可用。常见做法是用数据采集年份减去上牌年份得到车龄。这里有一个很多人忽略的问题采集年份应该用数据集对应的时间而不是运行代码时的系统当前时间。# 改成你手头数据集对应的采集年份 collect_year 2023 df[car_age] collect_year - df[注册年份] # 使用强度比单纯里程更能说明损耗 df[annual_mileage] df[行驶里程] / (df[car_age] 1) # 车龄与里程的交互项保留原列新增一列 df[age_mileage] df[car_age] * df[行驶里程]逻辑说明car_age和行驶里程是两个强特征但它们合在一起描述的是“一辆车的磨损程度”。一辆跑了 10 年、总里程 2 万公里的车和一辆 3 年、总里程 2 万公里的车后者明显更值钱。annual_mileage把“年均使用强度”单独抽出来比单看里程更能体现车况。age_mileage是车龄和里程的交互项给树模型提供了直接的交叉线索。参数说明代码里car_age 1是为了防止车龄为 0 时除零报错。collect_year不要直接写死成代码运行年份否则明年打开工程同样的车源预测语义都会漂移。如果数据集里同时有上牌日期和数辆采集日期优先用采集日期减上牌日期精确到月份更好但对课程作业来说按年估算已经够用。3.2 类别编码品牌这种高基数类别别一上来就独热品牌、车系这类特征动辄几十个类别直接用pd.get_dummies()会把一张几千行的表撑成几千列。对线性模型来说几千维稀疏特征不仅训练慢还容易把权重分摊到出现次数很少的类别上验证分数看着不错换一批数据就露馅。我一般会分两种思路处理。from sklearn.preprocessing import LabelEncoder # 低基数类别列做 0/1 映射变速箱、燃油类型 df[trans_type] df[变速箱].map({自动: 1, 手动: 0}) # 高基数类别做频次编码品牌出现次数代表市场流通热度 brand_count df[品牌].value_counts() df[brand_count] df[品牌].map(brand_count) # LabelEncoder 作为备选给模型一个整数编号 encoder LabelEncoder() df[brand_label] encoder.fit_transform(df[品牌])逻辑说明变速箱用人工映射是因为它的取值只有两三种0/1 编码既保留业务含义又不会膨胀维度。品牌用频次编码是因为畅销品牌的流通性好、保值率通常更有规律出现次数本身就是一个不错的近似。LabelEncoder只是给类别编号数值大小本身没有业务含义直接喂给线性模型会有“强行排序”的风险但喂给树模型基本没影响。参数说明value_counts()返回的是每个品牌出现的次数map会把训练集里见过的品牌映射成对应频次。如果预测时遇到没见过的品牌map会返回缺失值需要提前用fillna(0)兜底。编码方案的选择可以整理成一张对比表放进设计报告里。编码方式适用列优点风险0/1 映射变速箱、燃油类型有业务含义维度不变类别多时不适用LabelEncoder品牌编号简单树模型友好对线性模型强加顺序频次编码品牌、车系防止维度爆炸频次与价格并非完全正相关独热编码类别数小于 10可解释、无顺序假设类别多时列数膨胀3.3 相关性筛选特征一旦变多先看图再动手特征多了以后列和列之间经常是共线的。二手车领域最经典的共线性是“车龄”和“上牌年份”因为它们本质是同一件事的两种表达。共线性会让线性回归的系数解释失真也会让随机森林的特征重要性在几个强相关特征之间被摊薄。import seaborn as sns # 选出已经数值化的特征列 feature_cols [ car_age, 行驶里程, annual_mileage, 排量, trans_type, brand_count, price_log ] corr df[feature_cols].corr() sns.heatmap(corr, annotTrue, fmt.2f, cmapRdBu_r) plt.show()逻辑说明热力图的作用是快速揪出相关系数过高的特征对。看到|r| 0.7的组合我会倾向保留业务含义更直接的那个变量。比如car_age和annual_mileage如果有强相关保留annual_mileage可能更好因为它已经把车龄的信息折叠进去了。参数说明0.7 是经验阈值不是硬标准。特征维度超过 30 时可以用相关系数矩阵做一次粗筛把高相关的其中一列排除后再进模型。树模型对共线性不如线性模型敏感但如果你的设计报告里打算给回归系数做解释这一步就必须做。3.4 特征标准化树模型无所谓线性模型和正则化很敏感标准化这一步经常被跳过原因往往是“反正随机森林不要求标准化”。但如果你在作业里同时对比线性回归和 Lasso特征尺度不一致会直接影响正则化强度。from sklearn.preprocessing import StandardScaler num_features [ car_age, 行驶里程, annual_mileage, 排量, brand_count ] scaler StandardScaler() X_scaled scaler.fit_transform(df[num_features])逻辑说明StandardScaler把每列变成均值 0、标准差 1 的分布。线性回归里“行驶里程”原始数值可能是几万而“车龄”只有不到 20两者量级差太远Lasso 的正则化项会不公平地惩罚数值小的特征。标准化之后每个特征对正则化的影响才平等。参数说明这里有一个必须养成的习惯——fit_transform只应该用在训练集上。测试集必须用训练集拟合好的 scaler 去transform不能把全量数据放在一起 fit否则会把验证集的信息泄漏进训练过程导致验证分数虚高。后文避坑章节会专门展开这一点。4. 模型训练与调参用交叉验证选出第一个能看的回归模型4.1 数据集划分先留数据再谈模型test_size 与 random_state 固定下来建模前第一件事是划分数据集不能拿着全量数据去比较模型效果。很多人在这里只改了test_size忘了固定random_state结果每次跑出来的分数都不一样自己都分不清是模型进步了还是运气变好了。from sklearn.model_selection import train_test_split X df[feature_cols].copy() y df[price_log] # 回归任务用不到 stratify但可以按价格分位做分层采样 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 )逻辑说明random_state42的作用是让每次划分结果一样后续所有模型对比都在同一组车源上进行排除了随机性干扰。test_size0.2表示留出 20% 的数据做验证剩下 80% 用于训练。价格的log1p变换之后的标签分布已经接近正态直接按比例切即可。参数说明数据集在 5000 行以下时可以把test_size调到 0.15避免验证集太小导致分数抖动。如果价格分布仍然偏可以用pd.qcut(y, q5, labelsFalse)生成分层标签传给stratify保证训练集和验证集里各个价格段的样本比例一致。4.2 三头并进线性回归、随机森林、XGBoost 跑个基线不要一上来就调参先用默认参数把几个典型模型各跑一遍看哪个方向值得继续深入。对二手车这种几千行的表格数据随机森林和 XGBoost 通常比线性回归表现更好但线性回归作为基线能帮助你理解数据里的线性关系有多强。from sklearn.linear_model import LinearRegression, Lasso from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error import numpy as np models { linear: LinearRegression(), lasso: Lasso(alpha1.0), rf: RandomForestRegressor(n_estimators200, random_state42), } # XGBoost 需要单独安装放到循环外单独处理 try: from xgboost import XGBRegressor models[xgb] XGBRegressor( n_estimators200, learning_rate0.08, random_state42 ) except ImportError: print(xgboost not installed, skip) results {} for name, model in models.items(): model.fit(X_train, y_train) pred_log model.predict(X_test) pred_price np.expm1(pred_log) # 还原成真实价格 y_true_price np.expm1(y_test) # 标签同样从对数价格还原 mae mean_absolute_error(y_true_price, pred_price) results[name] mae for name, mae in sorted(results.items(), keylambda x: x[1]): print(name, round(mae, 2))逻辑说明这段代码的要点是把所有模型统一在同一套训练集、验证集和评价指标下比较。np.expm1在预测和真实标签两侧都要做否则 MAE 对数空间里的小数值会误导判断。MAE 用真实价格计算单位是元报出来的数字可以直接写进设计报告的实验表格。参数说明Lasso(alpha1.0)是 L1 正则化的强度alpha 越大、系数被压缩得越狠。RandomForestRegressor(n_estimators200)代表 200 棵决策树树的数量太多会导致训练变慢但精度提升有限。XGBRegressor里的learning_rate0.08是权重收缩系数值越小训练越慢但通常精度更高。4.3 调参顺序不要一上来就调 max_depth先用交叉验证稳住结论第一次跑实验的人最容易犯错的地方是拿到模型就调参调两三个参数后写个“最优结果”就收工。这样的结论换一次数据划分就可能完全反转。我的调参顺序是先看默认参数下哪个模型值得深入第二步上交叉验证第三步才动树相关参数。from sklearn.model_selection import KFold from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error kf KFold(n_splits5, shuffleTrue, random_state42) mae_list [] for train_idx, val_idx in kf.split(X): X_train_f, X_val_f X.iloc[train_idx], X.iloc[val_idx] y_train_f, y_val_f y.iloc[train_idx], y.iloc[val_idx] model RandomForestRegressor( n_estimators300, max_depth12, min_samples_leaf4, random_state42 ) model.fit(X_train_f, y_train_f) pred_val_log model.predict(X_val_f) mae mean_absolute_error( np.expm1(y_val_f), np.expm1(pred_val_log) ) mae_list.append(mae) print(mean MAE: %.0f % np.mean(mae_list))逻辑说明KFold(n_splits5)把数据切成 5 份每次用其中 4 份训练、1 份验证循环 5 次最后取平均。这样得到的分数比单次train_test_split更值得相信因为每一份数据都有机会当验证集。参数说明max_depth12限制每棵树最多 12 层太深容易过拟合训练集。min_samples_leaf4表示叶子节点上至少有 4 个样本才停止分裂这个值越大模型越平滑。调参顺序上先固定random_state再调n_estimators到曲线平稳最后动max_depth和min_samples_leaf并用这份表格做记录。参数典型起始值增大后果减小后果n_estimators200~300训练变慢精度提升有限预测方差变大max_depth8~15拟合更强易过拟合欠拟合min_samples_leaf2~8模型更平滑更容易捕获噪声learning_rate (xgb)0.05~0.1收敛变慢可能欠拟合5. 踩坑排查二手车价格预测的四个高频翻车现场5.1 特征泄漏把真实价格漏进训练特征分数高得吓人却没法用现象训练集和验证集上的 MAE 都很低低到几乎接近零误差但把模型拿去预测新数据时预测值大多集中在一个均值附近没有区分度。原因最常见于用pd.merge关联多个表时价格列被重复关联或者在特征工程阶段把一个和价格同源的字段留在了特征里。比如数据表里同时有“成交价”“最低报价”“新车指导价”如果你把“新车指导价”当成普通特征它和“成交价”几乎是同一条信息模型等于已经看到了答案。解决用X.columns.tolist()逐列过一遍特征名价格相关字段全部放到 y 侧。再打印模型的特征重要性如果价格特征排名靠前立刻回头查数据血缘。一个排查技巧是观察训练集 MAE 和测试集 MAE 的差值如果测试集误差远大于训练集先怀疑泄漏再怀疑过拟合。5.2 独热编码维度爆炸小样本被几千列稀疏特征带偏现象对品牌、车系、内饰颜色这类高基数列做独热之后特征列从十几个变成几千个训练时间明显变长线性模型的验证分数反而下降。原因pd.get_dummies()默认会对所有 object 列展开。几十个品牌乘上几年份、几个内饰颜色就是几百上千列而每辆车在这些列里只有极少数取值为 1绝大多数是 0稀疏矩阵里有效信息被稀释了。解决高基数列改用频次编码就像 3.2 节做的那样必须保留独热时把类别数量阈值卡在 10 以内超过 10 个类别的列不做独热。对课程作业来说频次编码比 target encoding 简单也不容易过拟合讲起来更好说。5.3 预测价格变成负数线性模型在分布边缘外推翻车现象线性回归在极低里程、极新车龄的边界样本上预测出负价格或者在最低配车型上算出小于零的成交价。原因线性模型本质是“特征加权求和”它不具备树模型那种分区间拟合的能力。当某个样本的特征组合落在训练分布边缘时模型会按线性趋势往外推预测值就可能掉到零以下。解决训练时用price_log做标签预测后用np.expm1还原能大幅降低出现负数的概率。如果还原后仍有少量负数加一行np.clip(pred, 0, None)兜底。更稳妥的做法是改用随机森林或 XGBoost树模型不会在区间外做线性外推。5.4 随机种子一换模型排名全变数据划分太脆弱现象第 4 章的基线对比里随机森林比 XGBoost 好一点把random_state42改成 0 之后排名直接反转。原因单次划分的验证集可能恰好抽到某一年份或者某一价格段的车源模型在该段上表现好整体分数就好看。划分太脆弱结论就站不住。解决改用KFold(n_splits5)做交叉验证并用价格分位数做分层抽样让每一折都覆盖各个价格区间。对比模型时固定random_state作为统一实验条件但最后要用多折平均分数下结论。交叉验证之后换几个种子排名不再大幅跳动结论才算可信。6. 从交付作业到可信预测误差分组验证与一个收尾技巧只看整体 MAE 是课程作业里最容易漏掉的一环。整体平均误差低不代表每个价格段都预测得好——低价车差几千块可能是 20% 的误差高价车差两万可能只有 5%。把误差按真实价格分层统计能直接看出模型在哪个区间失真这个表放进设计报告比单报一个 MAE 数字有说服力得多。import pandas as pd import numpy as np # 在测试集上完成预测后整理成评估表 df_eval pd.DataFrame({ true_price: np.expm1(y_test), pred_price: np.expm1(pred_log) }) # 价格区间按万元切分改成你数据集的实际价格量级 df_eval[price_bin] pd.cut( df_eval[true_price], bins[0, 8, 20, 100] ) err df_eval.groupby(price_bin, observedTrue).apply( lambda g: pd.Series({ mae: np.mean(np.abs(g[true_price] - g[pred_price])), mape: np.mean( np.abs(g[true_price] - g[pred_price]) / g[true_price] ), count: len(g) }) ) print(err.round(2))逻辑说明pd.cut按真实价格把测试样本分成若干区间然后分组计算平均绝对误差 MAE 和平均百分比误差 MAPE。低价区间的 MAE 绝对值小但 MAPE 可能很高高价区间的 MAPE 低但绝对误差大这两种情况对应的业务含义完全不同。答辩时被问到“为什么高价车误差更大”你可以直接指着这张表解释高价车样本量少、行情波动范围大而且评估指标本身对绝对误差敏感。参数说明bins[0, 8, 20, 100]的单位是万元这里按常见二手车价格量级划成 8 万以下、8 到 20 万、20 万以上三段具体边界要按数据集的成交价分布调整。observedTrue表示只显示实际存在的分组避免空区间干扰显示。我的一个习惯是把这段误差分组代码固定到工程最后作为默认输出每次改完特征、调完参数跑一遍就能看到模型到底在哪个价格段变好、哪个价格段退化。早期做作业时我只报整体 MAE被老师问了一句“低价车预测偏差占比多少”就答不上来后来把分组误差写到设计报告里反而变成了讲得最清楚的部分。这个习惯一直留到现在希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?