简介基于机器学习的二手车价格预测应用项目源码与配套数据集专为需要完成Python期末大作业或希望掌握机器学习建模全流程的学生与开发者设计。项目覆盖数据清洗、特征选择、标准化、交叉验证和模型评估等关键环节并同时提供LightGBM、支持向量机回归、多元线性回归以及CNN多种模型实现其中LightGBM部分还包含特征交叉与十折交叉验证的进阶对比方便读者理解优化过程。压缩包内共19个文件、约56MB以Python脚本和CSV数据为主体搭配npy权重、h5模型、ipynb交互式Notebook与README说明文件从原始数据、中间结果到最终模型均有保留目录模块清晰下载后即可运行调试。该资源获得指导老师认可并评为98分代码均经本地编译与严格调试可直接作为课程设计、期末报告或实战练手的完整参考。目前已有196人浏览学习适合正在寻找可靠机器学习项目范例的同学下载使用。1. 二手车价格预测一个能写进简历的 Python 机器学习实战选题如果你是计算机或者数据相关专业的学生期末大作业最怕两件事一是课题太水交上去被老师一眼看出是“Hello World 换皮”二是数据太难搞光爬虫和清洗就耗掉大半时间。二手车价格预测刚好卡在中间它是个标准的机器学习回归任务数据在网上唾手可得特征工程有得写模型可以从线性回归一路做到随机森林最后还能套一个 Flask 接口假装是“应用实现”无论从哪个角度看都能水出三五千行的报告和源码。更要紧的是这个课题的“下限”很低哪怕你只掌握 pandas 和 sklearn 的基本用法也能把流程跑通但它的“上限”也很高把数据清洗、特征处理、交叉验证、超参调优做完已经无限接近工业界的建模流程。所以很多人在期末选题时都会把它拉进候选名单原因无他——它就是机器学习入门阶段的“满分作文题”。接下来的内容我会按自己实际交作业时的顺序把这套思路从数据准备一直讲到模型落地部署中间穿插那些让新手最容易翻车的坑。2. 跑通二手车价格预测的前置准备数据集、依赖与回归评估口径2.1 公共数据集怎么选车型覆盖度与字段完整度虽然标题里写的是“源码数据集”但你交作业时大概率需要自己重新找一份更能说明问题的数据。常见做法是去 Kaggle 搜 “car price prediction”或者找 CarDekho、Craigslist 等平台分享出来的二手车交易记录。国内也有同学直接爬汽车之家的“车源”页面这里我不建议动这种实时爬虫因为网站结构一天变三次反爬机制也麻烦期末时间耗不起。选数据集时我一般会看三个硬指标首先是样本量低于 3000 行的数据基本撑不起机器学习项目的说服力其次是字段完整度至少要有生产年份year、已行驶里程km_driven、燃油类型fuel_type、变速箱transmission、卖家类型seller_type以及目标字段价格price第三是价格字段的分布如果它明显呈现长尾形态不要怕这是正常的后面用 log 变换就能压住。拿到原始 CSV 后第一件事不要急着建模先用df.head()加df.info()把字段类型摸一遍。看看哪些列是空值哪些列是字符串但写着数值比如“12.5 kmpl”这种带着单位的怪东西。这一步决定了你后面是顺风顺水还是全程踩坑。很多大作业失败的原因不是模型不行而是连数据长什么样都没看清就开始fit。2.2 Python 环境与依赖清单用 requirements.txt 把版本焊死很多同学交作业时喜欢在报告里写“环境为 Python 3.10”但老师拿到源码一跑发现 pandas 2.0 和 numpy 1.24 的兼容性直接炸了。成熟的工程习惯是提供一个 requirements.txt把关键依赖的最低版本锁住这样助教在自己的电脑上运行你的代码时才不会因为版本问题给你扣“无法复现”的分。pandas1.5.0 numpy1.23.0 scikit-learn1.2.0 matplotlib3.6.0 seaborn0.12.0 Flask2.2.0 shap0.42.0 jupyter这里把 shap 写进去是有原因的——到了论文或报告的最后你需要用 SHAP 图来解释你的模型为什么把某辆车估成这个价而不是只会打印几个评估数字。Flask 则是为了最后的“应用实现”部分哪怕只是本地起一个/predict接口也足够证明你的模型不是焊死在脚本里的死代码。装好环境后在终端跑一句python -c import sklearn, pandas; print(sklearn.__version__, pandas.__version__)确认没报错再继续。我见过太多人卡在这步装了十几次包最后发现是 Python 环境变量指向了系统自带的 2.7这种玄学问题排查一次就要浪费半天预先验证能省掉所有后续的心烦。2.3 回归任务不是分类先把 MAE / RMSE / R² 定下来这是整篇文章里我最想先说清楚的事。二手车价格预测是回归问题它的评估指标和分类问题的准确率完全两码事。你给老师交实验结果时如果只写“平均准确率 91%”那基本等于在告诉老师“你根本没搞懂回归任务是干什么的”。回归任务的核心评估指标有三个。**MAE平均绝对误差**表示预测价格和真实价格平均差多少钱比如 3000 意味着平均每辆车估差 3000 块这个指标最直观**RMSE均方根误差**会将大误差放大比如预测错了一辆 50 万的豪车RMSE 会瞬间飙升它能帮你观察到模型的极端失误**R²决定系数**则表示模型对价格方差的解释程度0.8 以上通常说明模型已经学到了数据的绝大部分规律。我一般会在模型训练开始前就把这三个指标的评估函数封装好因为后面不管是调参还是换模型都要拿它们做横向对比。可以用 sklearn.metrics 里现成的方法也可以自己手动算一遍手推一次能帮你想明白每个误差的定义边界这在期末答辩时是个加分项。from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np y_true [100000, 50000, 120000] y_pred [95000, 55000, 100000] mae mean_absolute_error(y_true, y_pred) rmse np.sqrt(mean_squared_error(y_true, y_pred)) r2 r2_score(y_true, y_pred) print(fMAE: {mae:.2f}) # 平均偏差 6666.67 print(fRMSE: {rmse:.2f}) # 大误差被放大因为第三辆差了 2 万 print(fR2: {r2:.2f}) # 模型解释能力这段代码的逻辑很简单将真实列表和预测列表喂进去依次输出三个指标。值得注意的是 RMSE 永远大于等于 MAE两者差距越大说明预测值里混杂了越多的极端偏差。后续建模时如果发现 RMSE 特别高第一反应不是去调参而是回头看数据里有没有价格特别极端或者特征特别奇怪的车源。3. 把二手车价格数据集洗干净特征工程的完整流程3.1 缺失值与异常值的处理边界别把低价车一棍子打死数据清洗是整个流程里最脏最累但也最核心的步骤。拿到原始数据后先用df.isnull().sum()看看哪些字段缺得厉害。对于缺失率超过 50% 的列直接扔掉对于缺失率较低的字段区分处理数值字段比如里程、排量用中位数填充类别字段比如变速箱类型用众数填充千万不要用平均值去填类别字段填出来就是个不存在的“手动自动变速箱”。异常值的处理则需要带点业务判断。很多人一看价格最低的五行只有 1000 块钱就顺手删掉了这是错误的。二手车市场里有不少报废车、抵押车、事故报废车流向黑市它们的价格确实低这些数据反而是真实的噪声。我一般只处理那些明显违背物理规律的异常值比如里程为负数、年份早于汽车发明时间、价格小于 1000 元人民币且描述里写着“全新”的这种数据直接把整行删掉。import pandas as pd import numpy as np df pd.read_csv(car_ads.csv) # 基础字段清洗删除无意义的 ID 列和全部为空的列 df.drop(columns[col for col in df.columns if df[col].isnull().all()], inplaceTrue) # 处理里程字符串比如 12.5万公里 转成数值 df[km_driven] df[km_driven].astype(str).str.extract(r(\d\.?\d*))[0].astype(float) * 10000 # 价格删除小于等于1000的噪声记录百元价多半是定金或二手贩子钓鱼 df df[df[price] 1000].reset_index(dropTrue) # 数值列缺失填充使用中位数 num_cols df.select_dtypes(include[float64, int64]).columns df[num_cols] df[num_cols].fillna(df[num_cols].median())代码里的str.extract(r(\d\.?\d*))是正则匹配作用是把字符串中的数字部分摘出来再乘以 10000 换算成公里数。价格过滤这里用了 1000 作为阈值如果你用的是海外数据集建议按当地的万元级价格重新设定阈值。清洗完成后养成立刻df.to_csv(cleaned_car.csv, indexFalse)的好习惯方便后面反复读取使用。3.2 类别特征编码与数值特征标准化注意独热和标准化的先后顺序表头里可能同时存在数值特征year、km_driven和类别特征fuel_type、transmission、seller_type。机器学习模型听不懂“汽油”和“柴油”这种字符串所以必须编码。这里最关键的坑是不要对全表做统一切换。类别特征中有序类别如车况等级较差 一般 良好 优秀用 LabelEncoder 映射成 0/1/2/3 即可无序类别如燃油类型只能用 OneHotEncoder这是为了防止模型错误地认为“柴油1、汽油2、电动3”之间存在大小关系。很多同学为了省事直接把所有字符串字段丢进pd.get_dummies(df)结果稀疏矩阵膨胀到几千列模型训练又慢又容易过拟合。数值特征则要做标准化StandardScaler让均值回到 0方差回到 1。为什么不能直接用原始数值因为年份的数值范围是 2010~2023里程的范围是 0~300000如果不缩放模型在计算距离或梯度时会被里程数主导年份的影响几乎可以忽略不计。这就是为什么要用 ColumnTransformer 在同一个管道里将两类处理并联from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder num_features [year, km_driven, engine_capacity] cat_features [fuel_type, transmission, seller_type, owner_type] preprocessor ColumnTransformer( transformers[ (num_scale, StandardScaler(), num_features), (one_hot, OneHotEncoder(handle_unknownignore), cat_features) ])这里handle_unknownignore的用处是如果验证集里出现一个训练集中没见过的品牌类别OneHotEncoder 不会直接报错而是会将其全部置为 0。这个参数在跑交叉验证时特别重要因为 sklearn 的交叉验证切分方法不能保证每个折里都包含全部类别。加上这个参数就能避开最常见的那条ValueError: Found unknown categories报错。3.3 切分训练集与验证集别让同一条车源出现在两边数据切分是另一个最能体现工程素养的细节。绝大多数人直接train_test_split(df, test_size0.2)然后随机抽样。这在普通场景没问题但对二手车这种数字化的信息数据如果不考虑重复记录和车辆唯一性就会出现非常尴尬的“数据泄漏”同一台车先出现在训练集里凭借记忆把价格背下来了之后又在验证集里被考到导致得分虚高。所以我在切分之前会先对车源的唯一标识做一次去重。如果原始数据里有car_name或者ad_id字段就以此作为分组键如果没有就需要拼接brand year km_driven fuel_type生成一个组合 ID。然后用按组划分的方式切分from sklearn.model_selection import GroupShuffleSplit df[group_key] df[name] _ df[year].astype(str) gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, valid_idx next(gss.split(df, groupsdf[group_key])) X_train df.iloc[train_idx].drop(price, axis1) y_train df.iloc[train_idx][price] X_valid df.iloc[valid_idx].drop(price, axis1) y_valid df.iloc[valid_idx][price]GroupShuffleSplit与普通切分的区别在于它接受一个groups参数能保证同一组的样本只进入切分的一侧。比如同一条车源有两条相似的在售记录它们在 raw 数据里是不同的行但模型看到它们就像看了两份同一题的答案。如果没有做这步切分你的验证指标会虚高几千块钱交作业时可能还行但一旦想参加线上比赛或实际部署就会暴露根本上的泛化问题。4. 用 scikit-learn 实现二手车价格预测从线性回归到随机森林4.1 最小可运行代码Pipeline 把清洗、编码、建模串起来在正式跑模型之前强烈建议你把特征工程部分整合进 Pipeline。Pipeline 的本质是“把清洗、编码、建模按顺序锁死”它可以保证你在交叉验证时每一次 fold 的预处理流程都不会不同。很多人踩过的坑是在for循环里手动重复“先填充、再编码、再 fit、再 predict”结果漏掉了某一步验证集被标准化器偷偷看了一眼导致指标好得不像话。以下是完整的最小可运行流程也是整个期末作业的主干代码import pandas as pd import numpy as np from sklearn.ensemble import RandomForestRegressor from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error, r2_score df pd.read_csv(cleaned_car.csv) X df.drop(price, axis1) y df[price] # 注意用 train_test_split 起步后续如有重复数据再替换成 GroupShuffleSplit X_train, X_valid, y_train, y_valid train_test_split(X, y, test_size0.2, random_state42) preprocessor ColumnTransformer([ (num_scale, StandardScaler(), [year, km_driven]), (one_hot, OneHotEncoder(handle_unknownignore), [fuel_type, transmission, seller_type]) ]) pipeline Pipeline([ (preprocess, preprocessor), (rf, RandomForestRegressor(n_estimators300, max_depth15, random_state42)) ]) pipeline.fit(X_train, y_train) preds pipeline.predict(X_valid) mae mean_absolute_error(y_valid, preds) r2 r2_score(y_valid, preds) print(fMAE: {mae:.2f} R2: {r2:.3f})上面这段代码里的ColumnTransformer负责把已有的列整理成模型吃的格式Pipeline.fit则会自动依次对训练集做编码、标准化然后训练模型。预测时它也会自动对验证集执行相同的转换不会多写一行代码。参数说明中RandomForestRegressor里的n_estimators300代表 300 棵决策树max_depth15限制树的深度防止单棵树过深导致对训练集死记硬背。初学者很容易把n_estimators推到 1000 以上然后抱怨训练慢实际上对单机跑这种小数据集来说300 棵树已经足够收敛再多树只是增加电费消耗不会显著改善 MAE。4.2 特征系数与特征重要性模型说里程比年份重要把随机森林跑完后不要急着交差你需要剥开模型看看它学到了什么规律这是报告里非常占篇幅的“结果分析与讨论”部分。随机森林天生自带feature_importances_属性可以直接画出特征重要性条形图import matplotlib.pyplot as plt # 取出 ColumnTransformer 编码后的特征名第二段独热码列名较长只取前5个查看 feature_names (pipeline.named_steps[preprocess] .named_transformers_[one_hot] .get_feature_names_out([fuel_type, transmission, seller_type])) all_features np.concatenate([ [year, km_driven], feature_names ]) imp pipeline.named_steps[rf].feature_importances_ importance_df pd.DataFrame({特征: all_features, 重要性: imp}).sort_values(重要性, ascendingFalse) print(importance_df.head(10))从我的跑数经验来看结果往往不出意外km_driven和year排在前两名两者合计占比可达 60% 以上剩下的fuel_type里纯燃油和插电混动的差异也会有一定份额。这说明二手车市场最看重的就是“车龄”和“里程”这两个硬指标配置和颜色之类的因素对价格影响很小。如果你用的是线性回归就换成查看coef_参数价格模型里year的系数是正值km_driven的系数是负值这能验证整个方案的合理性。如果发现模型学到的规律跟现实相反比如年份越大价格越低那八成是特征工程里出了 bug很可能你把年份加载成字符串类型被当成类别编码了。4.3 随机森林落地说网格搜索里的 n_estimators 是个伪命题许多人在调参时喜欢对n_estimators做网格搜索认为“树越多模型越强”。其实n_estimators是最不值得调的超参数因为它的收益曲线呈“陡降后平台期”300 棵树和 600 棵树的 MAE 差异可能只有 10 块钱但训练时间翻倍。真正值得花时间去调的参数就三个max_depth、min_samples_split、min_samples_leaf。这些控制着每棵树的生长复杂度。在跑随机森林之前先试一下决策树回归DecisionTreeRegressor跑出来的效果你会发现单树 MAE 惨不忍睹然后换成随机森林瞬间下降一截这个对比过程写进报告里非常加分它展示了你是真的理解了集成学习的方差削减原理。from sklearn.model_selection import GridSearchCV param_grid { rf__max_depth: [10, 15, 20], rf__min_samples_split: [2, 5, 10], rf__min_samples_leaf: [1, 2, 4] } grid GridSearchCV(pipeline, param_grid, cv5, scoringneg_mean_absolute_error, n_jobs-1) grid.fit(X_train, y_train) print(最佳参数, grid.best_params_) print(最佳 MAE, -grid.best_score_)注意参数名里要带rf__前缀这是 Pipeline 的层级访问语法。scoringneg_mean_absolute_error是为了让 GridSearchCV 把最小化 MAE 理解为最大化负 MAE。cv5表示五折交叉验证每个参数组合训练五次得到的结果才稳定。如果不写cvsklearn 默认给三折差距不大但五折更有说服力也更能防止偶然误差。训练时加上n_jobs-1可以把 CPU 的多核心利用起来大幅缩短网格搜索的时间。5. 二手车价格预测的 5 个典型避坑记录从数据泄漏到过拟合5.1 现象模型验证 R² 高达 0.95但可视化预测值时和真值差了一大截原因数据里存在大量重复车源。同一个二手车平台为了引流常常把同一台车用不同标题发好几遍比如“19 年大众迈腾”和“大众迈腾 2019 款精品车况”其实是同一辆车的两个广告切分时它们可能一条进训练集一条进验证集。模型相当于提前看见了答案使得得分虚高但真实世界的新数据表现极差。解决训练前必须做去重。用name year km_driven生成组合键删除重复度超过 80% 的记录或者直接用 GroupShuffleSplit 做按组切分。如果数据字段太少拼不出唯一键退而求其次的方法是把 train_test_split 的random_state固定住至少确保结果可复现。5.2 现象RMSE 非常高但 MAE 还算能看原因价格目标严重长尾分布。二手车价格区间从几千块到百万级别都有模型把均价算出来之后绝大多数车在 10 万块价位上都估得挺准一旦碰到一辆 90 万的奥迪性能车误差放大到 30 万平方后直接让 RMSE 爆表。解决对目标做对数缩放。训练前执行y_train_log np.log1p(y_train)让模型在 log 空间里回归预测完再执行np.expm1(pred)把结果还原。这样处理的好处是让模型更关注小价位车型的相对误差而不是被豪车的绝对值完全主导。展示训练代码时需要强调你最评估用的是还原后的 MAE这个细节能体现你对误差分布的思考。y_train_log np.log1p(y_train) model.fit(X_train, y_train_log) preds_log model.predict(X_valid) preds_actual np.expm1(preds_log) mae mean_absolute_error(y_valid, preds_actual) rmse np.sqrt(mean_squared_error(y_valid, preds_actual))5.3 现象OneHotEncoder 报错或者预测时特征维度不匹配原因数据里像“品牌”这种类别字段基数非常大动辄几十上百个。有些类别如“菲亚特strada”在训练集中只出现了一次OneHot 编码后生成了几千个稀疏列。更致命的是如果训练集中恰好某个品牌没有出现而验证集里出现了模型就炸了。解决在编码前对稀有类别做频次过滤把出现次数少于一定阈值的品牌统一映射为“其他”。常见做法是df[brand] df[brand].filter(lambda x: x.shape[0] 30, axis0).fillna(other)具体阈值根据样本量来定一般小于总样本量 1% 的类别都可以并进“其他”。同时OneHotEncoder 必须设置handle_unknownignore让编码器在遇到没见过的类别时输出全零向量而不是抛异常。5.4 现象标准化之后验证集的指标好得不真实原因手贱把 StandardScaler 对全量数据的fit_transform完再切分。比如scaler StandardScaler(); X_all scaler.fit_transform(X)然后train_test_split(X_all)这样测试集的均值和方差已经包含了训练集的信息属于典型的特征泄漏。这在期末作业的源码里非常常见而且因为指标确实会变好看很难自查到。解决使用 Pipeline 可以百分之百规避这个坑。在 Pipeline 里fit过程只会在训练数据上计算均值方差predict和score时只做 transform。如果你不用 Pipeline就需要手写scaler.fit(X_train); X_train_scaled scaler.transform(X_train); X_valid_scaled scaler.transform(X_valid)。每次写代码时都要默念测试集永远是陌生数据。5.5 现象网格搜索之后模型在新数据上性能远低于交叉验证结果原因GridSearchCV 在内部对训练集再切成了训练和验证如果你在之前已经用全量训练集做了特征选择、异常值剔除或者插补这些步骤同样会引入全局信息。比如你用全量数据的均值去填充缺失值再做交叉验证每个折都会偷看到“未来”的数据。解决所有预处理步骤必须放进 Pipeline 里或者在每一折交叉验证内部重新执行。对缺失值填充应该用 sklearn 的SimpleImputer放到 Pipeline 里它会自动在每个训练折上先fit再transform。如果实在嫌麻烦至少要在填充前手动把训练集和验证集分开绝不触碰任何来自验证集的行信息。这也是专业数据工程师和课程作业在代码上的分水岭。6. 把预测结果落地模型解释与最小部署验证6.1 用 SHAP 解释二手车价格预测随机森林这种黑盒模型确实难解释但你交期末报告时老师最想看到的恰恰是“你为什么觉得这个模型可信”。我一般用 SHAP 库来做集成模型的事后解释它可以给出每一辆车每个特征对预测价格的贡献值。比如某辆 3 年车龄的帕萨特被预测为 11 万SHAP 里year贡献 2 万km_driven贡献 -1 万这些数字可以非常直观地贴在报告里。import shap model_rf pipeline.named_steps[rf] preprocessor pipeline.named_steps[preprocess] X_processed preprocessor.transform(X_valid) explainer shap.TreeExplainer(model_rf, feature_perturbationtree_path_dependent) shap_values explainer.shap_values(X_processed[:100]) shap.summary_plot(shap_values, X_processed[:100], feature_namesall_features)注意这里喂给 TreeExplainer 的是已经经过预处理的矩阵而不是原始 DataFrame。同时早期的 SHAP 对 sklearn 的 ColumnTransformer 兼容性有点鸡肋所以我将 pipeline 拆开分别取出预处理器和模型先手动 transform 再解释。如果你装的版本比较新直接传pipeline原对象应该也能跑通但万一报错AttributeError就用上面这个拆解写法效果相同。6.2 用 Flask 起一个 /predict 接口要说这个项目的点睛之笔那必然是把离线模型包装成一个 HTTP 接口。很多同学的源码到这一步就停了但他们往往会在答辩中被打个措手不及——老师问“你实现的应用在哪”只能尴尬地展示 Jupyter Notebook。Flask 是最轻量级的方案不需要懂任何 Web 框架底层原理只需要定义一个函数处理请求并返回 JSON。from flask import Flask, request, jsonify import pandas as pd app Flask(__name__) # 全局加载一次模型避免每次请求都重新训练 model pipeline app.route(/predict, methods[POST]) def predict(): json_data request.get_json(forceTrue) df pd.DataFrame([json_data]) pred_log model.predict(df) pred_actual float(np.expm1(pred_log)[0]) return jsonify({predicted_price: round(pred_actual, 2)}) if __name__ __main__: app.run(port5000, debugFalse)请求发来看看效果用 curl 或者 Postman 都行发送一条包含year、km_driven、fuel_type等字段的数据接口会返回一个 JSON 价格。这里有个细微但关键的细节传入的 DataFrame 的列顺序必须和训练时的X_train一致否则ColumnTransformer会按列名匹配失败或者错位。建议在predict里先写一行df df[feature_names_in_raw]来固定列顺序。哪怕不做前端页面一个/predict接口已经能让你的项目脱离“脚本”范畴跨入“应用”的门槛。做完以上这些步骤整个项目从一批原始 CSV 变成了一个可解释、可交互、可扩展的微型系统。我在第一次提交期末作业时就是被“测试集标准化泄漏”和“重复车源泄漏”这两个看起来毫不相关的问题轮流毒打了三天总以为自己的随机森林写得不够好最后才意识到原来是切分和编码的次序出了问题。所以说建模越到后期越会明白二手车价格预测真正的难点从来不在算法而在数据流的边界控制。希望你下次遇到预测指标异常时先按上面这五条避坑清单排查一遍再回头怀疑模型能力。希望帮到你。本文还有配套的精品资源点击获取