二手车价格预测:从EDA到Baseline模型的完整实践与深度解析

发布时间:2026/8/2 6:53:16

二手车价格预测:从EDA到Baseline模型的完整实践与深度解析 1. 项目缘起从“天池”到“我的”二手车预测最近在整理数据竞赛的入门笔记翻到了之前在天池平台做的一个二手车交易价格预测项目。那个项目提供了一个非常经典的baseline基线模型很多新手都是照着它一步步敲代码跑出第一个分数。但说实话当时跟着做的时候总觉得有点“知其然不知其所以然”——代码是跑通了分数也出来了可为什么要做这些数据清洗特征工程那几步到底起了多大作用模型参数为什么这么设心里总是不太踏实。这其实也是很多朋友刚接触数据科学项目时的共同感受面对一个现成的baseline能跑起来但很难真正消化成自己的东西。所以我决定把这个项目重新拿出来不是简单地复现而是进行一次“深度解剖”。我会基于那个经典的天池baseline但每一步都会加入我自己的理解和追问重点放在探索性数据分析和构建一个真正有解释性的baseline上。目标不是追求一个多高的分数而是把“黑盒”打开让你看清楚数据从原始状态到模型输入中间到底经历了什么以及每一个决策背后的“为什么”。这个项目非常适合有一定Python和pandas基础想通过一个完整案例深入理解机器学习全流程的朋友。我们将聚焦于两个核心EDA和Baseline。EDA不是走马观花地画几个图而是带着问题去审视数据为后续的模型构建提供决策依据Baseline也不是随便丢一个模型进去而是要建立一个合理、可解释、能作为后续优化坚实起点的初始模型。2. 数据初窥与问题定义我们到底要预测什么在动手写任何代码之前我们必须先彻底搞清楚我们要解决什么问题以及数据长什么样。天池这个赛题的任务是根据二手车的各项属性预测其交易价格。这是一个典型的回归问题。首先我们加载数据并快速查看其结构。通常数据集会包含训练集和测试集。import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns %matplotlib inline # 加载数据 train_data pd.read_csv(used_car_train_20200331.csv, sep ) test_data pd.read_csv(used_car_testA_20200331.csv, sep ) print(f训练集形状: {train_data.shape}) print(f测试集形状: {test_data.shape})运行后我们可能得到类似“训练集: (150000, 31)测试集: (50000, 30)”的结果。这意味着我们有15万条训练样本每条样本有31个字段30个特征1个价格标签测试集有5万条样本没有价格标签。接下来我们查看数据的前几行和基本信息# 查看前几行 print(train_data.head()) # 查看数据基本信息 print(train_data.info()) # 查看数值型特征的统计摘要 print(train_data.describe())通过info()方法我们可以立刻发现一些关键问题缺失值哪些列存在NaN缺失的比例高吗例如notRepairedDamage列可能有很多“-”值在pandas中会被识别为object类型实际上代表缺失。数据类型哪些是数值型int64, float64哪些是分类型object分类型特征里有多少不同的取值特征含义结合赛题说明理解每个字段代表什么。例如name汽车名称属于高基数类别特征取值非常多。model车型也是类别特征。brand品牌。bodyType车身类型。fuelType燃油类型。gearbox变速箱类型。power发动机功率。kilometer行驶公里数。notRepairedDamage是否有未修复损坏。regDate,creatDate注册日期和广告创建日期可以衍生出“车龄”、“广告发布时长”等特征。price我们的预测目标交易价格。注意在实际操作中notRepairedDamage列中的“-”需要被处理为缺失值。regDate和creatDate需要转换成datetime格式以便计算时间差。这些都是在初步查看时就要记下来的待办事项。定义清楚问题回归预测并初步了解数据全貌后我们的EDA才有了明确的方向我们需要分析目标变量的分布分析各个特征与目标的关系检查异常值并为处理缺失值、编码分类变量、特征工程做准备。3. 深入探索性数据分析用问题引导分析EDA不是机械地画图而是带着一系列问题去分析数据让图形和统计量来回答问题。我会分几个核心问题来展开。3.1 目标变量分析价格分布正常吗我们首先关心要预测的值——价格。它的分布直接影响模型的选择例如是否需要对价格取对数。plt.figure(figsize(12, 5)) plt.subplot(1, 2, 1) sns.histplot(train_data[price], kdeTrue) plt.title(Price Distribution) plt.subplot(1, 2, 2) sns.boxplot(ytrain_data[price]) plt.title(Price Boxplot) plt.show() print(f价格偏度: {train_data[price].skew():.2f}) print(f价格峰度: {train_data[price].kurt():.2f})我的分析过程与发现分布形态直方图通常会显示价格严重右偏大部分车集中在较低价格区间少数豪华车价格极高拉长了尾巴。偏度远大于0峰度也可能很高。异常值箱线图会清晰显示存在大量的上侧异常点那些远离箱体的点。决策这种严重的偏态分布对许多线性模型不友好。常见的处理方法是进行对数变换。我们来验证一下plt.figure(figsize(12, 5)) plt.subplot(1, 2, 1) sns.histplot(np.log1p(train_data[price]), kdeTrue) # 使用log1p防止价格为0 plt.title(Log(Price1) Distribution) plt.subplot(1, 2, 2) sns.boxplot(ynp.log1p(train_data[price])) plt.title(Log(Price1) Boxplot) plt.show() print(f对数价格偏度: {np.log1p(train_data[price]).skew():.2f})变换后分布会更接近正态分布偏度和峰度值会大大降低。因此在构建模型时我们选择对目标变量price进行对数变换模型将预测log(price)最终预测结果再通过exp(prediction) - 1转换回来。这是回归问题中处理右偏态目标的经典操作。3.2 关键数值特征分析功率、公里数与价格有何关系接下来我们选取几个核心的数值特征如power功率、kilometer公里数分析它们与价格的关系并检查异常值。fig, axes plt.subplots(1, 2, figsize(15, 5)) # 功率 vs 价格取对数后关系更线性 axes[0].scatter(train_data[power], np.log1p(train_data[price]), alpha0.1, s2) axes[0].set_xlabel(Power) axes[0].set_ylabel(Log(Price1)) axes[0].set_title(Power vs Log(Price)) # 公里数 vs 价格 # 公里数通常是离散的取值可以用箱线图 sns.boxplot(xkilometer, yprice, datatrain_data, axaxes[1]) axes[1].set_title(Kilometer vs Price) axes[1].tick_params(axisx, rotation45) plt.tight_layout() plt.show()我的分析过程与发现功率散点图显示功率与价格对数大致呈正相关但存在大量功率为0或极低如12的异常点。这些点在现实中不合理可能是数据录入错误。此外功率上限也可能存在异常高值如几百千瓦的普通家用车。这些异常点会严重影响模型必须处理。公里数箱线图显示公里数是一个离散化特征例如5万公里10万公里等。整体趋势符合常识行驶里程越长价格中位数越低。但每个箱体内都有很大的价格范围说明公里数不是唯一决定因素。基于此的决策对于power我们需要设定一个合理的上下限。例如可以查看其分位数print(train_data[power].describe()) print(f功率500的数量: {(train_data[power] 500).sum()}) print(f功率0的数量: {(train_data[power] 0).sum()})假设我们发现99.9%的数据功率在400以内但存在一些600甚至上万的值。我们可以选择用上下限截断Winsorization例如将大于400的设为400小于10的设为10或视为缺失。这里的选择需要结合业务知识家用车功率范围和统计分布。对于kilometer我们可以将其视为有序的类别特征或者进行分段处理。3.3 类别特征分析品牌、车型如何影响价格类别特征如brand品牌、model车型是影响价格的关键。我们可以通过统计不同类别下的价格分布来分析。# 分析品牌对价格的影响 brand_price train_data.groupby(brand)[price].agg([mean, median, count]).sort_values(median, ascendingFalse) print(brand_price.head(10)) # 查看最贵的品牌 print(brand_price.tail(10)) # 查看最便宜的品牌 plt.figure(figsize(15, 6)) # 选取数量最多的前20个品牌绘制价格中位数的条形图 top_brands brand_price.nlargest(20, count).index sns.boxplot(xbrand, yprice, datatrain_data[train_data[brand].isin(top_brands)], ordertop_brands) plt.xticks(rotation90) plt.title(Price Distribution by Top 20 Brands (by count)) plt.show()我的分析过程与发现品牌效应明显豪华品牌如奔驰、宝马、奥迪的价格中位数显著高于普通品牌。这是非常强的信号。样本量差异大有些品牌样本量很少其价格统计可能不稳定。在后续编码时如目标编码需要考虑平滑处理防止过拟合。高基数特征name车名的取值可能成千上万直接进行One-hot编码会导致维度爆炸。对于这类特征通常采用计数编码统计该车名出现的次数、目标编码用该车名对应的价格均值/中位数来编码或直接舍弃。决策对于brand这类中等基数且重要的类别特征我们可以使用One-hot编码或目标编码。对于name在baseline阶段为了简单和防止维度灾难我倾向于先使用计数编码即用“该车名在训练集中出现的次数”作为一个新特征。出现次数多的车名说明是常见车型其价格可能更稳定出现次数少的可能是稀有或个性化车型价格波动大。3.4 时间特征工程从注册日期和创建日期能挖出什么regDate注册日期和creatDate广告创建日期是两座金矿。最直接的衍生特征是车龄。# 转换日期格式 train_data[regDate] pd.to_datetime(train_data[regDate], format%Y%m%d, errorscoerce) train_data[creatDate] pd.to_datetime(train_data[creatDate], format%Y%m%d, errorscoerce) # 计算车龄年 train_data[car_age] (train_data[creatDate] - train_data[regDate]).dt.days / 365.25 # 查看车龄分布及其与价格的关系 plt.figure(figsize(12,5)) plt.subplot(1,2,1) sns.histplot(train_data[car_age].dropna(), bins50) plt.title(Car Age Distribution) plt.subplot(1,2,2) sns.scatterplot(xcar_age, yprice, datatrain_data, alpha0.1, s5) plt.title(Car Age vs Price) plt.show()我的分析过程与发现异常值计算车龄时会发现有些车的“车龄”是负数注册日期晚于广告日期这明显是错误数据需要处理如设为缺失或根据分布进行修正。非线性关系散点图显示车龄与价格呈负相关但并非严格线性。新车车龄1年贬值最快之后贬值曲线逐渐平缓。这提示我们可以对车龄进行分桶处理例如划分为“准新车(0-1年)”、“青年车(1-3年)”、“中年车(3-7年)”、“老年车(7年以上)”将连续值转化为有序的类别可能比原始值效果更好。其他衍生还可以从creatDate中提取“广告发布的月份”、“星期几”等有时能反映季节性销售趋势但在baseline中可以先不做。决策在baseline中我会生成car_age特征并处理其中的异常负值例如用中位数填充或直接删除异常样本。同时我会尝试一个简单的分桶版本作为对比。4. 数据清洗与特征工程为模型准备“食材”基于EDA的发现我们现在可以系统地清洗数据并构造特征。这一步直接决定了模型“吃”进去的数据质量。4.1 异常值处理给数据“修剪枝叶”根据EDA我们锁定了几个需要处理的异常特征power功率我们使用分位数进行截断。# 查看功率的分布决定上下界 print(train_data[power].quantile([0.001, 0.01, 0.1, 0.9, 0.99, 0.999])) # 假设我们决定将功率限制在[10, 500]的区间内 power_low, power_high 10, 500 train_data[power] train_data[power].clip(lowerpower_low, upperpower_high) test_data[power] test_data[power].clip(lowerpower_low, upperpower_high)实操心得这个上下界的选择不是绝对的。你可以先设定一个宽松的边界如[5,600]跑一个baseline再逐步收紧观察模型效果变化。也可以对训练集和测试集分别计算分位数然后取并集作为截断边界防止数据泄露。car_age车龄处理负值和极大值。# 计算车龄 train_data[car_age] (train_data[creatDate] - train_data[regDate]).dt.days / 365.25 test_data[car_age] (test_data[creatDate] - test_data[regDate]).dt.days / 365.25 # 处理异常车龄不应为负也不应过大比如超过50年 train_data.loc[train_data[car_age] 0, car_age] np.nan train_data.loc[train_data[car_age] 50, car_age] np.nan # 对测试集做同样处理其他数值特征如v_0,v_1等匿名特征也需要检查其分布和异常值方法类似。4.2 缺失值处理填补数据的“空白”查看各列缺失情况missing_train train_data.isnull().sum() / len(train_data) missing_test test_data.isnull().sum() / len(test_data) missing_all pd.concat([missing_train, missing_test], axis1, keys[Train, Test]) print(missing_all[missing_all.sum(axis1) 0].sort_values(byTrain, ascendingFalse))常见列如notRepairedDamage用‘-’表示缺失、fuelType、gearbox、bodyType以及我们刚创建的car_age都可能存在缺失。处理策略高缺失率特征如果某列缺失率超过50%在baseline阶段可以考虑直接删除该特征因为其提供的信息可能不可靠且难以填补。类别特征缺失用众数mode填充或直接填充一个“未知”类别。例如fuelType缺失就填“unknown”。cat_cols [fuelType, gearbox, bodyType, notRepairedDamage] for col in cat_cols: # 先将‘-’替换为np.nan train_data[col].replace(-, np.nan, inplaceTrue) test_data[col].replace(-, np.nan, inplaceTrue) # 用众数填充 mode_val train_data[col].mode()[0] train_data[col].fillna(mode_val, inplaceTrue) test_data[col].fillna(mode_val, inplaceTrue)数值特征缺失用中位数median填充。中位数对异常值不敏感比均值更稳健。num_cols [car_age, v_0, v_1, ...] # 列出所有数值列 for col in num_cols: median_val train_data[col].median() train_data[col].fillna(median_val, inplaceTrue) test_data[col].fillna(median_val, inplaceTrue)注意填充用的统计量众数、中位数必须只从训练集计算然后用来填充训练集和测试集。这是防止数据泄露的关键一步。4.3 特征构造与编码将数据转化为模型语言这是特征工程的核心我们将原始数据转化为模型能更好理解的格式。从日期衍生特征# 除了车龄还可以尝试分桶 train_data[car_age_bin] pd.cut(train_data[car_age], bins[0,1,3,7,15,100], labels[0,1,2,3,4]) test_data[car_age_bin] pd.cut(test_data[car_age], bins[0,1,3,7,15,100], labels[0,1,2,3,4]) # 填充分桶可能产生的NaN由于原始car_age为NaN train_data[car_age_bin].fillna(-1, inplaceTrue) test_data[car_age_bin].fillna(-1, inplaceTrue)处理高基数类别特征name# 计数编码用该车名出现的次数作为新特征 name_count train_data[name].value_counts().to_dict() train_data[name_count] train_data[name].map(name_count) test_data[name_count] test_data[name].map(name_count) # 对于测试集中出现训练集未出现的name填充为1视为出现一次 test_data[name_count].fillna(1, inplaceTrue)有序类别特征编码像kilometer公里数本身是有序的我们可以将其映射为有序的数值。km_map {5000:0, 10000:1, 20000:2, 30000:3, 40000:4, 50000:5, 60000:6, 70000:7, 80000:8, 90000:9, 100000:10, 150000:11} train_data[kilometer_encoded] train_data[kilometer].map(km_map) test_data[kilometer_encoded] test_data[kilometer].map(km_map)普通类别特征编码对于brand,bodyType等在baseline中我们使用最简单的标签编码或频率编码。频率编码与计数编码类似但用的是频率次数/总数。# 频率编码示例brand brand_freq (train_data[brand].value_counts() / len(train_data)).to_dict() train_data[brand_freq] train_data[brand].map(brand_freq) test_data[brand_freq] test_data[brand].map(brand_freq) test_data[brand_freq].fillna(train_data[brand_freq].min(), inplaceTrue) # 处理新品牌删除无用特征像原始的name,regDate,creatDate等在衍生出新特征后可以考虑删除减少噪声。seller卖家类型和offerType报价类型在天池数据中几乎全是单一值没有预测能力可以直接删除。drop_cols [name, regDate, creatDate, seller, offerType] train_data.drop(columnsdrop_cols, inplaceTrue, errorsignore) test_data.drop(columnsdrop_cols, inplaceTrue, errorsignore)5. Baseline模型构建、训练与验证数据准备好后我们开始构建第一个模型。Baseline模型的目标是快速建立一个合理的、可复现的基准而不是追求极致性能。因此我选择LightGBM因为它对类别特征友好、速度快、且通常能取得不错的效果。5.1 准备训练数据与评估指标首先分离特征和目标变量并对目标变量进行之前讨论过的对数变换。# 假设我们已将所有特征处理完毕存储在 DataFrame X_train 和 X_test 中 # 目标变量 y_train np.log1p(train_data[price]) # 对数变换 # 划分训练集和验证集用于本地评估模型 from sklearn.model_selection import train_test_split X_tr, X_val, y_tr, y_val train_test_split(train_data.drop(columns[price]), y_train, test_size0.2, random_state42)评估指标采用赛题指定的平均绝对误差但注意我们的目标是log(price)所以评估时也需要将预测值转换回原始价格空间来计算MAE。from sklearn.metrics import mean_absolute_error def eval_mae(y_true, y_pred): 计算在原始价格空间上的MAE # y_true 是 log(price) y_pred 是模型预测的 log(price) # 转换回原始价格 price_true np.expm1(y_true) price_pred np.expm1(y_pred) return mean_absolute_error(price_true, price_pred)5.2 配置与训练LightGBM模型我们使用LightGBM的LGBMRegressor并设置一组保守的、不易过拟合的初始参数。import lightgbm as lgb # 定义模型参数 lgb_params { boosting_type: gbdt, objective: regression_l1, # 使用L1损失与MAE对齐 metric: mae, num_leaves: 31, # 保守的叶子数防止过拟合 learning_rate: 0.05, feature_fraction: 0.8, # 每次迭代使用80%的特征 bagging_fraction: 0.8, # 每次迭代使用80%的数据 bagging_freq: 5, verbose: -1, random_state: 42, n_jobs: -1, } # 创建数据集 dtrain lgb.Dataset(X_tr, labely_tr, categorical_feature[bodyType, fuelType, gearbox, notRepairedDamage] if categorical in locals() else auto) dval lgb.Dataset(X_val, labely_val, referencedtrain) # 训练模型 model lgb.train( lgb_params, dtrain, num_boost_round1000, # 设置一个较大的轮数配合早停 valid_sets[dtrain, dval], valid_names[train, val], callbacks[lgb.early_stopping(stopping_rounds50), lgb.log_evaluation(100)] # 早停每100轮打印一次 )参数选择解释objective: regression_l1因为评估指标是MAEL1损失所以直接使用L1损失作为优化目标理论上更直接。num_leaves: 31这是控制树复杂度的关键参数。对于baseline从一个较小的值开始如31确保模型不会太复杂。learning_rate: 0.05中等学习率配合足够的迭代轮数。feature_fraction和bagging_fraction这两个是随机森林思想的体现每次建树只使用部分特征和部分数据可以增加模型的鲁棒性防止过拟合是LightGBM的“防过拟合神器”。early_stopping: 这是必须使用的回调函数。它会在验证集性能不再提升时自动停止训练防止过拟合并自动选择最优的迭代轮数。5.3 模型评估与特征重要性分析训练完成后我们在验证集上评估并查看哪些特征最重要。# 在验证集上预测 val_pred_log model.predict(X_val, num_iterationmodel.best_iteration) # 计算MAE val_mae eval_mae(y_val, val_pred_log) print(f验证集MAE: {val_mae:.4f}) # 特征重要性 importance_df pd.DataFrame({ feature: X_tr.columns, importance: model.feature_importance(importance_typegain) # 使用增益 }).sort_values(importance, ascendingFalse) plt.figure(figsize(10, 12)) sns.barplot(ximportance, yfeature, dataimportance_df.head(20)) plt.title(Top 20 Feature Importance (Gain)) plt.tight_layout() plt.show()结果分析MAE值你会得到一个具体的MAE数值比如“验证集MAE: 0.1234”。这个数字本身的意义需要参考赛题的排行榜。更重要的是它为你后续的优化提供了一个基准。任何特征工程或调参都应该以降低这个值为目标。特征重要性图表会清晰显示哪些特征对模型预测贡献最大。通常name_count车名计数、brand_freq品牌频率、car_age车龄、power功率和某些匿名v特征会排名靠前。这验证了我们EDA和特征工程的方向是否正确。如果某个你认为重要的特征排名很靠后可能需要检查其编码方式或与目标的相关性是否真的不强。如果某个特征重要性异常高需要警惕是否存在数据泄露该特征间接包含了目标信息。踩坑实录我曾遇到过creatDate的衍生特征重要性异常高的情况。后来发现是因为测试集的时间范围在训练集之后模型学到了“日期越晚价格越高”的这种时间趋势这在真实预测中是无效的属于数据泄露的一种。解决方法是在特征工程中避免使用与时间序列未来信息相关的特征或者进行更严谨的时间序列交叉验证。6. 生成提交结果与Baseline总结最后我们用训练好的模型在测试集上进行预测并生成符合赛题要求的提交文件。# 对测试集进行预测同样是在对数空间 test_pred_log model.predict(test_data, num_iterationmodel.best_iteration) # 转换回原始价格空间 test_pred_price np.expm1(test_pred_log) # 生成提交DataFrame假设测试集有一个‘SaleID’列作为标识 submission pd.DataFrame() submission[SaleID] test_data[SaleID] # 请根据实际列名调整 submission[price] test_pred_price # 保存为CSV文件 submission.to_csv(baseline_submission.csv, indexFalse) print(提交文件已生成: baseline_submission.csv)至此一个从EDA到Baseline的完整流程就走完了。回顾整个过程我们不仅仅是跑通了一个流程更重要的是理解了每一步背后的动机EDA是导航它告诉我们数据哪里有问题异常值、缺失值哪里藏着金矿强相关特征指导我们如何进行清洗和特征工程。清洗是保障处理掉异常值和缺失值相当于给模型提供了干净、一致的“食材”避免模型学到错误规律。特征工程是核心将原始数据转化为对模型更友好的形式。Baseline阶段我们用了计数编码、频率编码、简单衍生车龄等方法这些已经能带来显著提升。模型是工具LightGBM是一个强大且高效的工具。在Baseline阶段我们采用保守参数和早停策略目的是获得一个稳定、可解释的基准而不是一个复杂难调的“黑箱”。这个Baseline的分数可能不会很高但它稳健、可解释、为后续优化提供了清晰的起点。你可以基于这个Baseline尝试更复杂的特征工程如交叉特征、多项式特征、更细致的调参、模型集成等每一步改进都可以与这个Baseline对比清晰地看到提升来自哪里。这才是从一个“跑代码的人”走向“解决问题的人”的关键一步。

相关新闻