基于机器学习的二手车价格预测Python源码:从数据清洗到Flask部署 简介这份资源是面向机器学习入门与进阶学习者的二手车价格预测完整项目源码采用Python实现适合想通过真实回归任务掌握数据清洗、特征工程与模型训练的开发者参考。压缩包共20个文件约99.6MB包含4个Python源码文件、5个XML配置、3个CSV数据文件以及说明文档、项目配置与开源协议等覆盖从数据到模型输出的完整流程。其中model.py负责模型构建与训练main.py作为程序入口协调流程baseline.py提供基准模型用于性能对比CSV数据则记录车辆品牌、年份、里程等属性便于直接复现实验。目前已有175人学习下载。读者可借此获得一套结构清晰的赛题级方案理解回归问题的建模思路、特征处理方式与基准对比方法并参考目录组织与配置管理快速搭建自己的预测实验环境。1. 从一份二手车 CSV 说起这个标题到底在解决什么问题二手车商收车时最怕的不是没车源而是定价拍脑袋。同一辆 2018 款 1.4T 的紧凑型轿车行驶 6 万公里、个人一手、无事故在 A 城市能挂 9.2 万到了 B 城市可能只值 8.4 万如果这台车还赶上过两次过户、右前翼子板钣金价格还得再往下压。传统做法是老师傅凭经验给个区间但经验没法复制也没法解释给客户听。基于机器学习的二手车价格预测 Python 设计源码这个标题讲的就是把这件事从「老师傅拍脑袋」变成「模型给区间 特征贡献可解释」的一套完整工程用 Python 把历史成交数据清洗成特征矩阵训练回归模型最后封装成一个能输入车况、输出估价和置信区间的脚本或小服务。它适合三类人一是想拿一个完整项目练手机器学习全流程的在校生和转行者二手车数据字段丰富、目标变量连续、业务含义直观比鸢尾花数据集有意思得多二是二手车平台或车商的技术人员想快速搭一个内部估价原型三是已经会调sklearn但没做过端到端项目的人这个标题能逼你把数据清洗、特征工程、模型对比、误差分析、部署接口全部走一遍。下面我按自己实际做过的路径把选型理由、可复现步骤和踩过的坑讲清楚。2. 数据到手先别急着建模字段清洗与特征工程的取舍2.1 二手车数据里哪些字段能进模型哪些必须扔拿到一份二手车 CSV第一件事不是df.describe()而是逐列判断它属于哪一类。常见字段大致分四组车辆固有属性品牌、车系、车型、排量、变速箱、燃油类型、车身结构、使用属性注册日期、行驶里程、过户次数、是否营运、车况属性事故记录、泡水、火烧、漆面修复、保养记录、交易属性所在城市、上牌时间、排放标准、新车指导价。目标列通常是成交价或挂牌价。这里有个反直觉的结论不是字段越多越好。像「车辆描述」这种自由文本如果直接做 One-Hot 会炸出上万维稀疏特征树模型还能扛线性模型基本废掉。我的做法是文本描述只提取几个关键词布尔特征比如「一手」「无事故」「4S 保养」「准新车」其余丢弃。另外「新车指导价」和「成交价」高度相关如果数据里同时有这两个字段要警惕它变成泄漏特征——模型会直接学「成交价 ≈ 指导价 × 折扣系数」看起来 R² 很高但换一批没有指导价的数据就崩。我一般会做两组实验带指导价和不带指导价对比测试集误差如果差距超过 15%就说明模型过度依赖它正式版要么去掉要么把它降级成「指导价分箱」这种粗粒度特征。还有一个容易忽略的点是时间泄漏。如果你用 2023 年的成交数据训练去预测 2021 年的车价模型会学到「2023 年同款车更贵」这种时间趋势但实际业务里你是用历史预测未来方向反了。正确做法是按上牌时间或成交时间排序取前 80% 做训练、后 20% 做测试而不是随机切分。2.2 缺失值、异常值和类别编码的具体处理脚本下面这段代码是我处理二手车数据时最常用的清洗骨架字段名按你实际 CSV 改。import pandas as pd import numpy as np df pd.read_csv(used_car.csv, encodingutf-8) # 1. 统一列名去掉空格和特殊字符 df.columns [c.strip().replace( , _) for c in df.columns] # 2. 价格列转数值去掉「万」「元」等单位 def parse_price(x): if pd.isna(x): return np.nan x str(x).replace(万, 0000).replace(元, ).replace(,, ) try: return float(x) except ValueError: return np.nan df[price] df[price].apply(parse_price) # 3. 行驶里程统一成公里 def parse_mileage(x): if pd.isna(x): return np.nan x str(x).replace(万公里, 0000).replace(公里, ).replace(,, ) try: return float(x) except ValueError: return np.nan df[mileage] df[mileage].apply(parse_mileage) # 4. 缺失值处理数值列用中位数类别列用「未知」 num_cols [mileage, displacement, new_price] cat_cols [brand, gearbox, fuel_type, city, emission] for c in num_cols: if c in df.columns: df[c] df[c].fillna(df[c].median()) for c in cat_cols: if c in df.columns: df[c] df[c].fillna(未知) # 5. 异常值价格低于 1000 或高于 500 万的直接剔除 df df[(df[price] 1000) (df[price] 5_000_000)] # 6. 车龄特征用当前年份减去上牌年份 df[reg_year] pd.to_datetime(df[reg_date], errorscoerce).dt.year df[car_age] 2024 - df[reg_year] df[car_age] df[car_age].clip(lower0, upper30) # 7. 类别编码品牌用目标编码变速箱等低基数用 One-Hot brand_mean df.groupby(brand)[price].mean() df[brand_target] df[brand].map(brand_mean) df pd.get_dummies(df, columns[gearbox, fuel_type], drop_firstTrue) df.to_csv(used_car_clean.csv, indexFalse) print(df.shape)逻辑说明价格和里程的单位清洗是第一步很多公开数据集里这两列是字符串直接astype(float)会报错。缺失值方面数值列用中位数而不是均值因为车价和里程都是右偏分布均值会被豪车拉高。类别列填「未知」而不是众数是为了保留「这条记录本来就没填」的信息树模型能学到「未知」可能对应某种车况。异常值阈值 1000 到 500 万是我根据国内二手车市场定的你可以按数据分布调整。车龄用clip截断防止出现 1900 年上牌这种脏数据导致负值或超大值。品牌目标编码要注意必须用训练集的均值映射到测试集否则测试集品牌均值会泄漏标签正确做法是在交叉验证的每一折内部单独计算。参数方面drop_firstTrue是为了避免 One-Hot 后的多重共线性对线性模型重要对树模型影响不大但能减少特征数。如果你用 LightGBM 或 XGBoost类别特征可以直接传category类型不需要手动 One-Hot效果通常更好。3. 模型选型线性回归、随机森林和 LightGBM 到底怎么选3.1 三个基线模型的训练与对比脚本二手车价格预测本质是回归问题但数据里既有高基数类别品牌、车系又有非线性关系车龄和折旧不是线性的前三年掉价快后面趋缓还有交互效应同样里程营运车比非营运车便宜更多。所以我的策略是先跑一个线性回归做基线再上随机森林看非线性最后用 LightGBM 冲精度。import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.linear_model import Ridge from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import lightgbm as lgb df pd.read_csv(used_car_clean.csv) # 特征列去掉目标、原始日期、文本列 drop_cols [price, reg_date, brand, model, desc] feature_cols [c for c in df.columns if c not in drop_cols] X df[feature_cols] y np.log1p(df[price]) # 价格取对数缓解右偏 # 按时间排序切分避免时间泄漏 df df.sort_values(reg_year) split int(len(df) * 0.8) X_train, X_test X.iloc[:split], X.iloc[split:] y_train, y_test y.iloc[:split], y.iloc[split:] def evaluate(name, model): model.fit(X_train, y_train) pred model.predict(X_test) pred np.expm1(pred) # 还原到原始价格 true np.expm1(y_test) mae mean_absolute_error(true, pred) rmse np.sqrt(mean_squared_error(true, pred)) r2 r2_score(true, pred) print(f{name}: MAE{mae:.0f}, RMSE{rmse:.0f}, R2{r2:.4f}) return model ridge evaluate(Ridge, Ridge(alpha1.0)) rf evaluate(RandomForest, RandomForestRegressor( n_estimators300, max_depth12, min_samples_leaf5, n_jobs-1, random_state42)) gbm evaluate(LightGBM, lgb.LGBMRegressor( n_estimators800, learning_rate0.05, num_leaves63, min_child_samples20, subsample0.8, colsample_bytree0.8, random_state42))逻辑说明价格取log1p是因为车价分布右偏直接回归会让模型偏向高价车取对数后误差更接近百分比误差业务上更合理——估 10 万的车差 5000 和估 50 万的车差 5000意义完全不同。切分按reg_year排序后取前 80%模拟真实场景。三个模型的参数是我在几份二手车数据上试出来的起点Ridge 的alpha1.0是默认正则强度如果特征共线性严重可以调到 10随机森林max_depth12防止过拟合min_samples_leaf5让叶子节点至少有 5 个样本避免记住噪声LightGBM 的num_leaves63是经验值数据量小于 5 万行时别超过 127否则容易过拟合。跑完你会看到典型结果Ridge 的 R² 大概 0.75 到 0.82随机森林能到 0.85 到 0.90LightGBM 通常再高 2 到 4 个百分点。但别只看 R²MAE 才是业务方关心的——它直接告诉你平均估错多少钱。如果 LightGBM 的 MAE 比随机森林只低 500 块但训练时间长 3 倍、调参复杂度高一个量级那随机森林可能是更务实的选择。3.2 特征重要性怎么看以及哪些特征在业务上说不通树模型训练完model.feature_importances_能给出特征重要性但默认是分裂次数或增益高基数类别容易排前面。我一般用 LightGBM 的plot_importance配合 SHAP 值交叉验证。下面这段代码输出前 15 个重要特征和 SHAP 摘要。import shap import matplotlib.pyplot as plt explainer shap.TreeExplainer(gbm) shap_values explainer.shap_values(X_test) # 特征重要性条形图 shap.summary_plot(shap_values, X_test, plot_typebar, max_display15) plt.tight_layout() plt.savefig(shap_importance.png, dpi150) # 单个样本解释为什么这辆车估了 8.6 万 sample_idx 0 shap.force_plot(explainer.expected_value, shap_values[sample_idx], X_test.iloc[sample_idx], matplotlibTrue) plt.savefig(shap_single.png, dpi150)逻辑说明SHAP 的好处是每个样本都有解释业务方问「为什么这辆车估 8.6 万」你能指出「车龄 5 年贡献 -1.2 万里程 8 万公里贡献 -0.8 万品牌保值率贡献 0.5 万」。参数上max_display15控制显示特征数太多图会糊。如果发现「城市」特征重要性异常高要警惕可能是某些城市样本量太少模型把城市当成了价格标签这时候要么合并稀有城市要么把城市从特征里去掉改用「一线/新一线/其他」这种粗粒度分箱。业务上说不通的特征必须处理。我遇到过「过户次数」重要性排前三但仔细看数据发现过户次数多的车往往也是车龄大的车模型其实在学车龄只是过户次数和车龄共线。这时候要么做特征交叉要么把过户次数二值化成「是否一手」。另一个坑是「排量」1.5L 和 2.0T 的价格差异在不同品牌间方向相反豪华品牌大排量更贵经济型品牌大排量反而因为油耗高贬值所以排量最好和品牌做交互特征或者直接交给树模型自动学。4. 避坑与排查二手车价格预测里最容易翻车的五件事4.1 数据泄漏R² 0.98 的模型上线就废现象离线评估 R² 高达 0.98MAE 只有几百块但换一批新数据预测误差直接翻三倍。原因最常见的是「新车指导价」或「同款车当前挂牌均价」混进了特征。这两个字段和成交价几乎线性相关模型直接抄答案。另一个隐蔽来源是目标编码没做折内计算测试集品牌均值泄漏了标签。解决训练前逐列检查与目标的相关性超过 0.95 的特征直接删或降级。目标编码必须用sklearn的TargetEncoder配合cross_val或者手动在每折训练集上算均值。时间序列数据严格按时间切分别用train_test_split(random_state42)。4.2 类别特征高基数品牌车系 One-Hot 后维度爆炸现象pd.get_dummies之后特征从 30 列变成 2000 多列训练慢内存爆线性模型系数全乱。原因品牌有几百个车系有几千个每个都 One-Hot 就是几千维稀疏矩阵。解决高基数类别用目标编码或频率编码低基数变速箱、燃油类型才用 One-Hot。LightGBM 和 CatBoost 原生支持类别特征直接传category类型让模型自己找最优分裂比手动编码效果好。如果非要用 One-Hot把出现次数少于 50 的类别合并成「其他」。4.3 价格取对数后忘记还原MAE 看起来很美现象训练时对价格取log1p评估时直接算 MAE得到 0.15 这种数字以为误差只有 0.15 元。原因对数空间的误差和原始空间误差不是一个量级log1p后的 0.15 对应原始价格可能差几千块。解决评估前必须np.expm1(pred)还原。另外注意对数空间的最小化 MSE 等价于原始空间的百分比误差如果你关心绝对误差可以不取对数改用 Huber 损失或直接优化 MAE。4.4 缺失值填 0 而不是中位数模型学到「0 公里 新车」现象里程缺失的记录被填成 0模型认为这些是准新车估价偏高。原因fillna(0)是默认习惯但 0 在里程和排量上有实际含义填 0 等于引入错误信息。解决数值列用中位数或分组中位数按品牌分组填类别列填「未知」。更稳妥的做法是加一列「是否缺失」的布尔特征让模型自己决定怎么处理缺失。4.5 用随机切分做时间相关数据未来信息泄漏到过去现象随机切分后 R² 0.92按时间切分后掉到 0.85。原因随机切分让模型在训练时看到了未来年份的车学到了时间趋势但实际预测时未来数据不可见。解决按上牌时间或成交时间排序前 80% 训练、后 20% 测试。如果数据时间跨度大还可以做滚动窗口验证用 2018-2020 训练、2021 测试再用 2019-2021 训练、2022 测试看模型稳定性。5. 从脚本到能用的估价接口Flask 封装与批量预测技巧模型训练完只是半成品业务方要的是「输入车况输出价格」。最轻量的做法是用 Flask 包一个/predict接口接收 JSON返回估价和区间。下面是我常用的最小服务代码。from flask import Flask, request, jsonify import pandas as pd import numpy as np import joblib app Flask(__name__) model joblib.load(lgbm_model.pkl) feature_cols joblib.load(feature_cols.pkl) app.route(/predict, methods[POST]) def predict(): data request.get_json() row pd.DataFrame([data]) # 补齐训练时存在的特征列缺失的填 0 for c in feature_cols: if c not in row.columns: row[c] 0 row row[feature_cols] log_pred model.predict(row)[0] price np.expm1(log_pred) # 用训练集残差标准差给一个粗略区间 std 0.18 # 对数空间残差标准差按你的模型改 low np.expm1(log_pred - 1.96 * std) high np.expm1(log_pred 1.96 * std) return jsonify({ price: round(float(price), 0), low: round(float(low), 0), high: round(float(high), 0) }) if __name__ __main__: app.run(host0.0.0.0, port5000)逻辑说明加载模型和特征列清单保证输入特征顺序和训练时一致。缺失特征填 0 是兜底正式版应该返回错误提示。区间用对数空间残差标准差乘 1.96 近似 95% 置信区间std这个值要从你的验证集残差算出来别直接用 0.18。启动后curl -X POST http://localhost:5000/predict -H Content-Type: application/json -d {car_age:5,mileage:80000,brand_target:85000}就能拿到估价。批量预测时别一条条调接口直接把 DataFrame 传给model.predict速度差几十倍。如果数据量大用lgb.Dataset的predict配合num_iteration控制树的数量还能进一步加速。最后说个我自己的习惯每次模型更新我都会留一份「后悔药」——把旧模型和旧特征列打包存到models/20240101/目录新模型上线后如果业务反馈误差变大能快速回滚对比。这个习惯帮我省过至少两次线上事故。希望帮到你。本文还有配套的精品资源点击获取