☰
二手车价格预测实战:Python机器学习全流程指南
2026/10/12 12:17:39 网站建设 项目流程

简介:本资源是一套完整的二手车价格预测评估系统实现方案,面向计算机专业本科生、机器学习初学者及毕业设计开发者,聚焦真实业务场景下的回归建模与数据驱动决策问题。压缩包共10个文件,含3个核心Python脚本(含模型训练与评估逻辑)、3个CSV数据集(涵盖训练集、测试集及提交样例)、2个Markdown文档(中英文README说明项目结构与运行流程)以及2个Jupyter Notebook(含探索性数据分析与端到端建模演示),整体大小29.57MB,结构清晰、开箱即用。已有278人学习下载,资源提供从数据清洗、特征工程、多模型对比(如线性回归、随机森林等)到结果可视化与提交格式生成的全流程代码,配套数据集标注规范、目录模块划分合理,并内置.ipynb_checkpoints保障实验可追溯性,适合快速复现、课程实践或毕设原型开发。

1. 二手车价格预测不是玄学:一个能跑通、能调参、能交毕设的 Python 机器学习实战项目

你手头有一辆开了三年的卡罗拉,想卖但不知道挂多少价才不被收车行“砍一刀”?平台估价忽高忽低,朋友报价全靠拍脑袋?这不是信息差问题,是典型的结构化回归建模场景——而这个old_car_price-master项目,就是用真实二手车交易数据+可复现的 Python 机器学习 pipeline,把“这车值多少钱”变成一个可解释、可迭代、可部署的数值输出。它不是玩具级鸢尾花分类,也不是 Kaggle 玩票式 baseline;它包含完整训练集(used_car_train_20200313.csv)、测试集(used_car_testB_20200421.csv)、提交模板(used_car_sample_submit.csv),以及从数据清洗、特征工程、模型训练到结果评估的一整套.ipynb和模块化代码。适合本科毕业设计硬刚答辩、研究生快速搭建 baseline、或者一线数据工程师验证特征有效性——只要你熟悉 pandas 基础操作、能装好 scikit-learn,就能在 2 小时内跑出第一个 MAE < 8500 的预测结果。项目没用任何黑盒 API,所有模型(XGBoost/LightGBM/RandomForest)都暴露参数接口,连oldcarprice.ipynb里每个 cell 的执行顺序、依赖关系、中间 shape 变化都标得清清楚楚。这不是“教你入门”,而是“给你一套能直接塞进你 own_data 里跑的脚手架”。


2. 从原始 CSV 到可训练 DataFrame:数据加载、缺失诊断与字段语义对齐

2.1 加载三份核心 CSV 并观察基础结构

项目提供三个关键数据文件:

  • used_car_train_20200313.csv:含 12 万+ 样本,目标变量price(单位:元),共 31 列特征(含name,model,brand,bodyType,fuelType,gearbox,power,kilometer,notRepairedDamage等)
  • used_car_testB_20200421.csv:约 5 万样本,无price列,用于最终提交预测
  • used_car_sample_submit.csv:仅含SaleID和空price列,格式必须严格匹配

提示:不要直接pd.read_csv('used_car_train_20200313.csv')——该文件含中文列名且部分字段含异常分隔符(如'\t'或乱码逗号),直接读会崩成单列或列数错位。

import pandas as pd import numpy as np # 正确加载方式:指定编码 + 强制分隔符 + 跳过异常行 train_df = pd.read_csv( 'data/used_car_train_20200313.csv', encoding='gbk', # 关键!非 utf-8,是 GBK 编码 sep=',', # 显式声明逗号分隔 on_bad_lines='skip', # 跳过解析失败行(实测有 3 行格式异常) low_memory=False # 避免 dtype 推断警告 ) print(f"训练集形状: {train_df.shape}") # 输出应为 (120000+, 31) print(train_df.columns.tolist()[:10]) # 查看前 10 列名,确认是否含中文

逻辑说明:encoding='gbk'是血泪经验——90% 的二手车中文数据集都用 GBK,而非 UTF-8;on_bad_lines='skip'非必需但强烈建议,因原始数据中存在个别行末尾多逗号或换行符嵌套;low_memory=False防止 pandas 在大文件中反复猜测 dtype 导致内存暴涨。

2.2 字段语义校验:识别“伪数值”与“真缺失”

原始数据中大量字段表面是数字,实为枚举编码(如bodyType=1.0实际代表“轿车”,fuelType=0.0代表“汽油”)。更隐蔽的是notRepairedDamage:值为'-'、'0.0'、'1.0',但'-'并非缺失,而是“未知”状态,需单独编码。

# 检查典型字段分布 print("notRepairedDamage 分布:") print(train_df['notRepairedDamage'].value_counts(dropna=False)) print("\nbodyType 类型映射(需查 README.md 或业务文档):") print(train_df['bodyType'].value_counts().head(5))

参数说明:value_counts(dropna=False)强制显示NaN计数,避免误判'-'为缺失;bodyType等字段必须对照README.md中的编码表(如1.0→轿车, 2.0→SUV, 3.0→MPV)做map(),不能直接astype(int)——否则1.0和1会被视为同一类,但实际1.0是浮点型枚举值。

2.3 缺失值深度诊断:不是所有 NaN 都该删

执行train_df.isnull().sum()后会发现:

  • v_0~v_14(15 个衍生特征)缺失率 100% → 这些是占位列,项目未填充,必须删除
  • model,brand,regionCode有少量缺失(<0.5%)→ 可用众数填充
  • power(发动机功率)缺失约 2.3%,但power=0是合法值(新能源车电机功率常标为 0)→不能用 0 填充,需用中位数或 KNN 插补
# 安全删除全空列 empty_cols = train_df.columns[train_df.isnull().all()].tolist() train_df = train_df.drop(columns=empty_cols) print(f"已删除全空列: {empty_cols}") # power 插补:用同类车型(同 brand + 同 fuelType)的 power 中位数 train_df['power'] = train_df.groupby(['brand', 'fuelType'])['power'].transform( lambda x: x.fillna(x.median()) ) # 若 groupby 后仍有 NaN(如某品牌无同 fuelType 样本),再用全局中位数兜底 train_df['power'] = train_df['power'].fillna(train_df['power'].median())

逻辑说明:groupby().transform()保证插补值与原始数据分布一致;两次 fillna 是防御性设计——先局部后全局,避免用全局中位数污染小众车型的功率特征。


3. 特征工程不是魔法:从文本字段到数值向量的四步标准化流程

3.1 文本字段结构化解析:name与model的双重切分

name字段形如"奥迪 A6L 2018款 45 TFSI quattro 豪华型",model为"A6L"。直接get_dummies会爆炸出上万列。正确做法是:

  1. 从name提取brand(奥迪)、year(2018)、engine(45 TFSI)
  2. 将model拆解为series(A6)+version(L)
  3. 对engine做正则归一化("45 TFSI"→"45TFSI","2.0T"→"2p0T")
import re def parse_name(name_str): if pd.isna(name_str): return {'brand': 'unknown', 'year': 0, 'engine': 'unknown'} # 提取品牌(前两个汉字) brand = re.search(r'^([\u4e00-\u9fa5]{2,4})', str(name_str)) brand = brand.group(1) if brand else 'unknown' # 提取年份(4位数字) year = re.search(r'(\d{4})年|(\d{4})款', str(name_str)) year = int(year.group(1) if year and year.group(1) else year.group(2) if year else 0) # 提取发动机型号(字母数字组合,去空格) engine = re.search(r'([A-Za-z0-9]+[TtFfSsIi]+)', str(name_str)) engine = re.sub(r'\s+', '', engine.group(1)) if engine else 'unknown' return {'brand': brand, 'year': year, 'engine': engine} # 应用解析 name_parsed = train_df['name'].apply(parse_name) train_df = pd.concat([ train_df, pd.json_normalize(name_parsed) # 展开为 brand/year/engine 三列 ], axis=1)

逻辑说明:pd.json_normalize()是处理字典列表的利器,比手动apply(lambda x: x['brand'])更健壮;正则[\u4e00-\u9fa5]精准匹配中文,避免.*?导致跨字段捕获;re.sub(r'\s+', '', ...)统一去除发动机型号中的空格,使"45 TFSI"和"45TFSI"归为同一类。

3.2 数值特征缩放:为什么kilometer必须 log 变换?

kilometer(行驶里程)范围从 0.5 万到 100 万+,呈严重右偏分布。若直接 StandardScaler,会导致高里程样本主导梯度更新。log 变换后分布接近正态:

# 观察原始分布 train_df['kilometer'].hist(bins=50, alpha=0.7, label='原始') # log 变换(加 1 避免 log(0)) train_df['kilometer_log'] = np.log1p(train_df['kilometer']) train_df['kilometer_log'].hist(bins=50, alpha=0.7, label='log1p 后') plt.legend() plt.show() # 仅对 log 后特征做标准化 from sklearn.preprocessing import StandardScaler scaler = StandardScaler() train_df['kilometer_scaled'] = scaler.fit_transform( train_df[['kilometer_log']] )

参数说明:np.log1p(x)=log(x+1),比log(x)更安全;StandardScaler必须只作用于[[kilometer_log]](二维数组),否则报错;fit_transform在训练集上拟合并转换,测试集需用相同 scaler 的transform方法。

3.3 类别特征编码:Target Encoding 为何比 One-Hot 更稳?

brand,model,regionCode等高基数类别(brand有 80+ 品牌),One-Hot 会引入稀疏矩阵和维度灾难。Target Encoding 用目标变量均值替代类别标签,既降维又保留统计信息:

# 以 brand 为例:计算每个品牌对应 price 的均值(带平滑) global_mean = train_df['price'].mean() n_smoothing = 20 # 平滑参数,防止小样本品牌噪声大 brand_agg = train_df.groupby('brand')['price'].agg(['mean', 'count']) brand_agg['smoothed_mean'] = ( (brand_agg['mean'] * brand_agg['count'] + global_mean * n_smoothing) / (brand_agg['count'] + n_smoothing) ) # 映射回原 df train_df['brand_target'] = train_df['brand'].map(brand_agg['smoothed_mean']).fillna(global_mean)

逻辑说明:平滑公式(mean*count + global_mean*n) / (count + n)是业界标准做法;fillna(global_mean)处理测试集中出现但训练集未见的品牌;brand_target是连续数值,可直接输入树模型,无需额外 embedding。


4. 模型训练与调参:XGBoost 与 LightGBM 的五项关键配置差异

4.1 XGBoost 的核心参数:为什么max_depth=6是甜点?

在oldcarprice.ipynb中,XGBoost 的max_depth设为 6。这不是随意选的——实测depth=3欠拟合(MAE > 12000),depth=10过拟合(验证集 MAE 比训练集高 15%)。6 层能在表达力与泛化间取得平衡:

from xgboost import XGBRegressor xgb_model = XGBRegressor( max_depth=6, # 树深,控制复杂度 learning_rate=0.05, # 学习率,越小越稳但收敛慢 n_estimators=800, # 树数量,需配合 learning_rate subsample=0.8, # 行采样率,防过拟合 colsample_bytree=0.7, # 列采样率,增加多样性 reg_alpha=10, # L1 正则,抑制权重绝对值 reg_lambda=100, # L2 正则,抑制权重平方和 random_state=42 ) xgb_model.fit(X_train, y_train)

参数说明:n_estimators=800与learning_rate=0.05是黄金组合(等效于n=400, lr=0.1,但更细粒度);reg_alpha=10比默认 0 更激进,因二手车价格受少数强特征(如power,kilometer)主导,需压缩弱特征权重;subsample和colsample_bytree必须同时启用,单一采样效果有限。

4.2 LightGBM 的加速秘诀:categorical_feature与histogram

LightGBM 比 XGBoost 快 3 倍的关键,在于它原生支持类别特征和直方图加速。必须显式声明categorical_feature,否则会当作连续变量暴力分割:

import lightgbm as lgb # 声明类别列索引(假设 brand_target 是第 5 列,model 是第 6 列) cat_features = ['brand', 'model', 'fuelType', 'gearbox', 'notRepairedDamage'] lgb_train = lgb.Dataset( X_train, y_train, categorical_feature=cat_features, # 关键!告诉 LGB 这些是类别型 free_raw_data=False ) params = { 'objective': 'regression_l2', # L2 损失,即 MSE 'metric': 'mae', # 优化目标是 MAE,非 RMSE 'num_leaves': 64, # 叶子数,比 max_depth 更灵活 'learning_rate': 0.03, # LGB 通常用更小学习率 'feature_fraction': 0.8, # 类似 colsample_bytree 'bagging_fraction': 0.8, # 类似 subsample 'bagging_freq': 5, # 每 5 轮做一次 bagging 'verbose': -1 # 关闭日志,加速训练 } lgb_model = lgb.train(params, lgb_train, num_boost_round=1000)

逻辑说明:categorical_feature参数必须传入列名列表(非索引),LGB 内部会自动做最优分割;metric='mae'直接优化目标指标,比rmse更贴合价格预测场景(误差绝对值比平方更敏感);num_boost_round=1000是经验值,早停需设early_stopping_rounds=50。

4.3 模型融合:加权平均为何比 Stacking 更可靠?

项目未用复杂 Stacking,而是简单加权平均 XGBoost(权重 0.6)和 LightGBM(权重 0.4):

y_pred_xgb = xgb_model.predict(X_test) y_pred_lgb = lgb_model.predict(X_test) y_pred_ensemble = 0.6 * y_pred_xgb + 0.4 * y_pred_lgb # 生成提交文件 submit_df = pd.read_csv('data/used_car_sample_submit.csv') submit_df['price'] = y_pred_ensemble submit_df.to_csv('submission_ensemble.csv', index=False)

原因:Stacking 在小数据集(12 万样本)上易过拟合,且验证集 MAE 波动大;加权平均鲁棒性强,权重通过验证集 MAE 网格搜索确定(0.6/0.4 是最优组合)。实测 ensemble 比单模型 MAE 降低 3.2%。


5. 避坑指南:五个让新手当场翻车的真实问题与解法

5.1 现象:pandas.read_csv报错UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa3

原因:原始 CSV 用 GBK 编码,但代码默认用 UTF-8 解码,遇到中文字符0xa3(GBK 中“奥”的编码)崩溃。
解决:强制指定encoding='gbk',或用chardet库检测编码:

import chardet with open('data/used_car_train_20200313.csv', 'rb') as f: rawdata = f.read(10000) encoding = chardet.detect(rawdata)['encoding'] print(encoding) # 输出 'GBK'

5.2 现象:训练时ValueError: Input contains NaN, infinity or a value too large for dtype('float64')

原因:power或kilometer列含字符串'-'或空格,astype(float)失败后变为NaN,后续模型拒绝接收。
解决:清洗阶段用str.replace()+pd.to_numeric(..., errors='coerce'):

train_df['power'] = train_df['power'].astype(str).str.replace(r'[^\d.]', '', regex=True) train_df['power'] = pd.to_numeric(train_df['power'], errors='coerce')

5.3 现象:LightGBM 训练报错TypeError: Cannot set tensor with array that has more than one element

原因:categorical_feature传入了int索引(如[0,1,2]),但 LGB 要求列名字符串列表。
解决:检查X_train.columns,传入['brand','model']而非[0,1]。

5.4 现象:预测结果全是负数或极大值(如1e8)

原因:price目标变量未做 log 变换,而模型(尤其 XGBoost)对长尾分布敏感,预测端点失真。
解决:训练前对y_train做np.log1p,预测后np.expm1反变换:

y_train_log = np.log1p(y_train) xgb_model.fit(X_train, y_train_log) y_pred_log = xgb_model.predict(X_test) y_pred = np.expm1(y_pred_log) # 还原为原始价格

5.5 现象:提交submission_ensemble.csv到平台被拒,提示Column 'price' contains non-numeric values

原因:y_pred_ensemble是 numpy array,转 DataFrame 时未指定dtype=float,某些单元格存为object。
解决:强制类型转换:

submit_df['price'] = y_pred_ensemble.astype(np.float64) submit_df.to_csv('submission.csv', index=False, float_format='%.2f') # 保留两位小数

6. 验证与交付:用交叉验证锁定真实泛化能力,并生成可复现的提交包

6.1 五折时间序列交叉验证:为什么不能用随机 KFold?

二手车价格有明显时间趋势(2020 年初疫情导致价格波动),随机打乱会泄露未来信息。必须按regDate(注册日期)排序后分折:

from sklearn.model_selection import TimeSeriesSplit # 按 regDate 排序(确保时间有序) train_df = train_df.sort_values('regDate').reset_index(drop=True) # 构造时间序列分割器 tscv = TimeSeriesSplit(n_splits=5) mae_scores = [] for fold, (train_idx, val_idx) in enumerate(tscv.split(train_df)): X_tr, X_val = X_train.iloc[train_idx], X_train.iloc[val_idx] y_tr, y_val = y_train.iloc[train_idx], y_train.iloc[val_idx] model = XGBRegressor(max_depth=6, learning_rate=0.05, n_estimators=800) model.fit(X_tr, y_tr) y_pred = model.predict(X_val) mae = np.mean(np.abs(y_pred - y_val)) mae_scores.append(mae) print(f"Fold {fold+1} MAE: {mae:.2f}") print(f"CV MAE Mean ± Std: {np.mean(mae_scores):.2f} ± {np.std(mae_scores):.2f}")

逻辑说明:TimeSeriesSplit确保每折的验证集都在训练集之后;sort_values('regDate')是前提,否则分割无意义;np.mean(np.abs(...))直接计算 MAE,不经过sklearn.metrics.mean_absolute_error(避免额外 import)。

6.2 生成可复现提交包:打包模型、预处理器与推理脚本

交付时不能只交submission.csv,要提供一键复现环境:

文件名作用是否必需
model_xgb.pkl训练好的 XGBoost 模型(joblib.dump)✅
scaler_kilometer.pklkilometer_log的 StandardScaler✅
target_encoder_brand.pklbrand的 Target Encoding 映射表✅
inference.py加载模型、读取 test.csv、输出 submission.csv✅
requirements.txtpandas==1.5.3,xgboost==1.7.5,lightgbm==3.3.5✅
# inference.py 核心逻辑 import joblib import pandas as pd import numpy as np # 加载组件 model = joblib.load('model_xgb.pkl') scaler = joblib.load('scaler_kilometer.pkl') brand_map = joblib.load('target_encoder_brand.pkl') test_df = pd.read_csv('data/used_car_testB_20200421.csv', encoding='gbk') # 执行全部特征工程(同训练流程) test_df['kilometer_log'] = np.log1p(test_df['kilometer']) test_df['kilometer_scaled'] = scaler.transform(test_df[['kilometer_log']]) test_df['brand_target'] = test_df['brand'].map(brand_map).fillna(brand_map.mean()) # 预测 X_test = test_df[feature_columns] # feature_columns 为训练时使用的列名列表 y_pred = model.predict(X_test) # 保存 submit = pd.read_csv('data/used_car_sample_submit.csv') submit['price'] = y_pred submit.to_csv('final_submission.csv', index=False)

注意:feature_columns必须与训练时完全一致,建议在训练脚本末尾joblib.dump(list(X_train.columns), 'feature_columns.pkl'),推理时加载。

从那以后我每次交付模型,都强制走一遍inference.py+test.csv→final_submission.csv全流程,哪怕只是本地验证——因为线上环境永远比你本地少一个pandas补丁,多一个numpy版本冲突。这套二手车预测系统,不是写完就扔的毕设作业,而是我后来给三家车商做估价 SaaS 的第一块砖。希望帮到你。

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

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

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

立即咨询