机器学习驱动的二手车价格预测系统实现全流程
2026/9/23 19:21:52 网站建设 项目流程

简介:面向二手车价格预测的完整机器学习项目,针对二手车市场价格评估难的问题,通过数据分析建立预测模型,涵盖数据清洗、特征分析、线性回归建模、交叉验证调优与基于Flask的预测网站实现,适合学习回归任务和模型落地的开发者。压缩包共71个文件,包含Python脚本、Jupyter Notebook分析文档、训练好的.pkl模型、CSV数据集,以及用于前端展示的图片和样式脚本(HTML/CSS/JS等),整体大小仅3.12MB,结构清晰。资源已有1996人学习,内容实用。项目中,利用Pandas对二手车交易数据进行预处理,包括缺失值删除与填补、异常值剔除,并选用线性回归进行训练,配合交叉验证评估模型。同时提供可直接运行的Flask Web应用,实现价格预测功能。附带原始与清洗后的CSV数据、Python分析脚本以及.ipynb逐步分析文档,方便对照复现。对于需要课程设计或快速了解机器学习项目完整流程的读者,这份资源能提供很好的参考。

1. 二手车价格预测系统实现前的关键判断

基于机器学习的二手车价格预测系统,核心不在于把模型堆得多深,而在于让估价链路从原始车辆信息变成可交付的预测服务。输入品牌、车龄、里程、排量、变速箱和车况描述,输出一档价格或区间,再包装成 API 供估价页调用;这套系统能不能落地,往往取决于特征口径是不是稳定,而不是模型结构是否够新。

一个完整的二手车价格预测系统实现,至少包括数据清洗、特征构造、模型训练、模型持久化和接口封装。这里只讲机器学习范围内的做法,不引入深度学习,因为表格型二手车特征用集成树模型就足够。适合正在做机器学习项目或准备做数据应用开发的人参考。

2. 二手车价格预测系统的数据清洗与特征工程

2.1 先检查价格分布,再决定要不要缩尾处理

多数二手车价格预测项目的数据来自平台车源记录或线下导入,字段常包括上牌日期、表显里程、新车指导价、排量、变速箱、排放标准和车系。第一个要看的不是特征,而是目标值 price,因为价格分布直接决定后续该用 RMSE 还是带权误差来评估。

初次看数据时,我一般会先跑一段代码确认数据规模和价格分布:

import pandas as pd df = pd.read_csv("car_source.csv") print(df.shape) print(df["price"].describe()) # 过滤异常价格:小于8000元的记录大概率是误写或过户工本费 df = df[df["price"] >= 8000] # 大于99分位的价格做截断,避免个别超跑标价干扰模型 price_upper = df["price"].quantile(0.99) df = df[df["price"] <= price_upper]

这段代码用describe()查看价格均值、四分位数和最大值,再用业务经验把价格下限设为 8000 元,上限取 99 分位数。8000 这个阈值不是固定的:只做 10 万以上车型要调高下限,只做廉价代步车则要压低上限。截断的目的是防止模型为了拟合极少数高价车而牺牲大部分普通车的预测精度。

如果发现count明显小于原始行数,说明 price 存在空值,需要先看缺失记录:

print(df[df["price"].isna()].head())

价格缺失的记录占比低于 5% 时直接删除;占比高就需要按车系补全,否则样本会偏向未缺失的那部分价格区间。

2.2 日期与里程加工成模型可用的特征

原始数据里的上牌日期一般是字符串,表显里程可能是带“万公里”的文本。车龄是二手车价格最重要的特征之一,所以必须精确到月,而不仅仅是年份。

import numpy as np # 上牌日期转成月级车龄,精度比年份更细 reg_date = pd.to_datetime(df["register_date"], errors="coerce") df["age_month"] = (pd.Timestamp.now() - reg_date) / np.timedelta64(1, "M") # 表显里程统一为万公里数值 df["mileage"] = df["mileage"].astype(str).str.replace("万公里", "").astype(float)

车龄age_month的单位是月,建模时不需要再除以 12,树模型对数值尺度不敏感,线性模型会自己学系数。里程统一成万公里后,与价格的数量级更接近,后续解释特征权重也更直观。

里程缺失的常见做法是按车系分组填充中位数,而不是全局填同一个值:

df["mileage"] = pd.to_numeric(df["mileage"], errors="coerce") df["mileage"] = df.groupby("brand")["mileage"].transform( lambda x: x.fillna(x.median()) )

使用groupby之前要先确保brand列没有空值,否则会丢失一部分样本。填充完成后可以再跑一次df["mileage"].isna().sum(),确认没有剩余缺失。

2.3 类别特征转换成数值向量

品牌、车系、变速箱、燃油类型都是类别特征。车系数量可能上百甚至上千,直接 one-hot 会生成大量稀疏列,对树模型是性能浪费,对线性模型更容易造成共线性问题。

我一般先把低频品牌合并或用频次编码,再对少数类别做 one-hot:

# 品牌频次编码,保留出现次数这个信息 brand_freq = df["brand"].value_counts().to_dict() df["brand_freq"] = df["brand"].map(brand_freq) # 变速箱、燃油类型做 one-hot df = pd.get_dummies(df, columns=["transmission", "fuel_type"])

频次编码把一个品牌压缩成一个数值,等于告诉模型“这个品牌在样本里出现过多少次”,对热门品牌有天然的先验信息。get_dummies会把transmission拆成transmission_自动transmission_手动等。如果某个类别占比极低,可以先映射成other再做 one-hot,避免出现训练集有但预测请求没有的列。

2.4 可用特征清单与划分验证

经过清洗和构造后,特征矩阵大致如下:

字段名类型处理方式原因
age_month数值由上牌日期计算车龄是价格衰减第一驱动因素
mileage数值统一为万公里并填充里程直接影响磨损和剩余价值
brand_freq数值频次编码品牌类别多,压缩维度并保留热度
engine_power数值缺失用中位数填充动力参数对价格有正影响
transmission_自动0/1one-hot自动挡溢价明显
transmission_手动0/1one-hot手动挡对应低价车比例高

划分数据前,先检查特征矩阵是否还有空值,因为后续reindexpredict阶段对空值非常敏感:

from sklearn.model_selection import train_test_split X = df.drop(columns=["price", "car_id", "register_date"]) y = df["price"] assert X.isna().sum().sum() == 0, "存在未处理的缺失值" X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 )

random_state=42保证每次重跑结果一致;test_size=0.2在数据量低于 2 万条时也可以改成 0.3,让测试集更稳定。

3. 机器学习模型选型与评估:从线性回归到集成模型

3.1 先用线性回归建立基线

选模型之前,我会先让线性回归跑一版。它虽然假设价格与特征之间是线性可加的,但能提供一个明确的下限:如果后续模型比线性回归提升不到 5%,说明改进重点应该在特征构造,而不是盲目换复杂模型。

from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, mean_absolute_error lr = LinearRegression() lr.fit(X_train, y_train) pred_lr = lr.predict(X_test) print("Linear RMSE:", mean_squared_error(y_test, pred_lr, squared=False)) print("Linear MAE:", mean_absolute_error(y_test, pred_lr))

mean_squared_error(..., squared=False)返回 RMSE,单位是元;MAE 更接近业务上“平均差多少钱”的描述。如果 RMSE 明显大于 MAE,说明测试集中仍存在少量价格极端样本,即使训练时做了截断,高价车部分依然没有被完全覆盖。

# 看残差分布,确认是否有系统性高估或低估 residual = y_test - pred_lr print(residual.quantile([0.1, 0.5, 0.9]))

残差中位数如果小于 0,表示预测价格普遍偏高;90 分位和 10 分位跨度如果超过 5 万元,说明模型对低价车或高价车的表现差异很大,需要按价格区间进一步分桶评估。

3.2 随机森林与 XGBoost 的取舍

二手车特征是典型的表格型数据,车龄、里程与价格之间不是简单线性关系,因此线性模型只能做基线。常见做法是随机森林和 XGBoost 都试一遍,看验证集表现再定。

模型优点主要缺点优先调参项
线性回归训练快,解释性强非线性拟合弱,易受共线性影响特征标准化,删除高相关列
随机森林不容易过拟合,适合中小数据外推能力差,高基数列干扰重要度n_estimators, max_depth, min_samples_leaf
XGBoost非线性强,支持正则化和早停调参成本高,小样本容易过拟合learning_rate, max_depth, subsample

实现时我会先跑随机森林,因为它对异常值和缺失值更宽容,且不需要做特征标准化:

from sklearn.ensemble import RandomForestRegressor rf = RandomForestRegressor( n_estimators=200, max_depth=12, min_samples_leaf=4, random_state=42, n_jobs=-1 ) rf.fit(X_train, y_train) pred_rf = rf.predict(X_test) print("RF RMSE:", mean_squared_error(y_test, pred_rf, squared=False)) print("RF MAE:", mean_absolute_error(y_test, pred_rf))

n_estimators=200在几万条样本内已经足够稳定,再增大收益很小但训练时间线性增长。max_depth=12防止树生长过深导致过拟合;min_samples_leaf=4让叶子节点至少有 4 个样本,可以平滑价格噪声。

如果随机森林的 MAE 还在可接受范围之外,再上 XGBoost:

import xgboost as xgb xgb_model = xgb.XGBRegressor( learning_rate=0.05, max_depth=5, n_estimators=500, subsample=0.8, colsample_bytree=0.8, random_state=42 ) xgb_model.fit(X_train, y_train) pred_xgb = xgb_model.predict(X_test) print("XGB RMSE:", mean_squared_error(y_test, pred_xgb, squared=False)) print("XGB MAE:", mean_absolute_error(y_test, pred_xgb))

learning_rate=0.05是常用起点,为补偿小学习率,把n_estimators提到 500。subsample=0.8表示每轮迭代采样 80% 的行,colsample_bytree=0.8表示每棵树用 80% 的列,这两个参数组合能有效减少过拟合。数据量少于 3 万行时,max_depth不建议超过 6,否则训练集上表现很好,测试集容易崩。

3.3 特征重要度要看排序,也要看置换结果

随机森林输出feature_importances_很方便,但它会被高基数列带偏。比如car_id如果误留,它会因为能完美划分单个样本而排到前面,所以不能只依赖这个值。

importance = pd.Series( rf.feature_importances_, index=X_train.columns ).sort_values(ascending=False) print(importance.head(10))

如果结果里brand_freq排在前面而mileage掉出前三,需要怀疑里程字段填充比例过高。更稳妥的方式是计算置换重要性:

from sklearn.inspection import permutation_importance perm_res = permutation_importance( rf, X_test, y_test, n_repeats=5, random_state=42, scoring="neg_mean_absolute_error" )

置换重要性的逻辑是打乱某一列,看模型误差上升多少。它对高基数列不友好,但不会因为树的分裂次数而给出错误信号。n_repeats=5表示每列打乱 5 次取平均,运行时间大约是模型预测时间的 5 倍,适合在模型定稿前做一次。

3.4 用交叉验证代替单次划分

单次train_test_split的结果随机波动较大,模型最终要上线,我一般再看一次交叉验证:

from sklearn.model_selection import cross_val_score cv_mae = -cross_val_score( rf, X_train, y_train, cv=5, scoring="neg_mean_absolute_error" ) print(cv_mae.mean(), cv_mae.std())

cv=5对二手车这种万级样本是常见选择。sklearn 的neg_mean_absolute_error是越高越好,所以取负号后就是常规 MAE。如果标准差大于均值的一半,说明模型在不同子集上表现波动大,需要回到特征工程检查区域或品牌分布是否失衡。

4. 预测系统实现:把机器学习模型封装成可调用的服务

4.1 固定特征列顺序,再持久化模型

训练时不固定列顺序,上线后用pd.DataFrame构造输入就会列对齐错乱,导致模型预测结果完全不可信。因此训练完要同时保存模型和特征列名。

import joblib joblib.dump(rf, "car_price_model.joblib") joblib.dump(list(X_train.columns), "feature_columns.joblib")

rf是上一章训练好的随机森林模型。feature_columns.joblib保存的是列名列表。预测服务启动时加载这两个文件,按同一个顺序构造输入。

4.2 Flask 提供单条预测接口

最小可用版本是一个 Flask 服务,接收 JSON 请求体,返回预测价格。代码结构分为加载模型、构造输入、预测三部分:

from flask import Flask, request, jsonify import joblib import pandas as pd app = Flask(__name__) model = joblib.load("car_price_model.joblib") feature_columns = joblib.load("feature_columns.joblib") @app.route("/predict", methods=["POST"]) def predict(): data = request.get_json() df = pd.DataFrame([data]) df = df.reindex(columns=feature_columns, fill_value=0) price = model.predict(df)[0] return jsonify({ "predicted_price": round(float(price), 2) }) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)

reindex(columns=feature_columns, fill_value=0)是关键,它保证请求里缺哪个字段就用 0 补齐,顺序也严格按照训练时列名顺序来。但用 0 补缺失字段有风险,例如手动挡字段填 0 会变成自动挡,所以接口层需要先做必填字段校验。

4.3 接口参数校验规则

至少要有车龄、里程、品牌频次和变速箱类型,否则模型预测的是缺字段样本。常用校验规则如下:

字段名是否必填取值范围失败处理
brand_freq大于 0返回 400,提示品牌信息缺失
age_month1 到 400超过 400 按边界截断
mileage0 到 100超过 100 拒绝预测
transmission_自动0 或 1缺失时按 0 处理

校验逻辑可以放在predict函数开头:

from flask import abort required = ["brand_freq", "age_month", "mileage"] for col in required: if col not in data: abort(400, description=f"缺少字段 {col}")

abort会返回带错误信息的 HTTP 400 响应,比前端看到 500 更容易定位问题。这里建议把错误信息也写入日志,方便后续统计哪些字段经常缺失。

4.4 批量预测脚本与日志记录

线上除了单条接口,还经常需要 CSV 批量预测。脚本比接口更简单:

original_df = pd.read_csv("to_predict.csv") batch_df = original_df.reindex(columns=feature_columns, fill_value=0) batch_df["predicted_price"] = model.predict(batch_df) assert len(batch_df) == len(original_df), "行数不一致" batch_df.to_csv("predicted_result.csv", index=False)

reindex可能会因为原始表索引错位而丢行,所以必须加assert校验行数。如果to_predict.csv有 1000 行,结果也必须 1000 行。

价格属于敏感业务数据,每次预测都要有日志。简单做法是在predict里记录请求和响应:

import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("car_price") logger.info("request: %s", data) logger.info("response: %s", predicted_price)

日志最终可以接到后端的监控告警里,而不是永远没人看。请求参数和预测结果对调整模型阈值非常重要。

5. 预测系统上线后的验证技巧与模型更新策略

5.1 用真实成交记录对比预测误差

上线后的系统不能只看训练时的测试集误差。常见做法是每天把已成交车辆的真实价格和系统预测价格放到一起,算误差分位数。

real_df = pd.read_csv("actual_transactions.csv") pred = model.predict(real_df.reindex(columns=feature_columns, fill_value=0)) real_df["pred_price"] = pred real_df["error_rate"] = (real_df["pred_price"] - real_df["price"]).abs() / real_df["price"] print(real_df["error_rate"].quantile([0.5, 0.9, 0.95]))

中位数误差率超过 10%,说明模型在主价格带上已经偏离;90 分位误差率高,说明有的车预测得离谱,需要检查是否出现了训练集里没有的新车型或改装车。

5.2 用 PSI 检测输入分布漂移

二手车市场变化快,半年前的模型特征分布可能已经不再适用。检测方法里 PSI 比较直观:把训练集预测值作为基准,当前批次预测值作为新样本,看两者分布差异。

import numpy as np def psi(expected, actual, bins=10): hist_e, edges = np.histogram(expected, bins=bins) hist_a, _ = np.histogram(actual, bins=edges) pct_e = hist_e + 1e-6 pct_a = hist_a + 1e-6 pct_e = pct_e / pct_e.sum() pct_a = pct_a / pct_a.sum() return ((pct_a - pct_e) * np.log(pct_a / pct_e)).sum()

调用时把训练集预测值和当天预测值分别传入。PSI 小于 0.1 表示稳定,0.1 到 0.25 需要观察,大于 0.25 就要触发重训。这里的edges复用基准分布的分箱,保证两次对比使用同一套区间。

5.3 重训与模型版本管理

重训前要把旧模型保存到带时间戳的目录,常见结构是models/20240101/models/20240201/。预测服务加载时优先读最新目录,发现误差变大时能快速回滚。

import glob latest = sorted(glob.glob("models/*"))[-1] model = joblib.load(latest + "/car_price_model.joblib")

glob按目录名排序时,时间戳最好用YYYYMMDD格式,这样字典序和日期序一致。重训脚本挂到每周任务里,自动训练、替换、比对误差,再配合 PSI 监控就能形成一套简单的模型自维护闭环。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询