二手车价格预测EDA实战:从数据探索到特征工程全流程
1. 赛题背景与EDA的核心价值定位二手车交易价格预测本质是一个典型的回归类结构化数据挖掘任务。给定一辆车的品牌、行驶里程、注册时间、变速箱类型、燃油类型、车身类型、功率、地区编码等字段要求预测其交易价格。这类赛题在数据挖掘学习路径中非常经典因为它同时具备几个特征数据量大、特征类型混杂类别型数值型文本型、缺失值比例不低、目标变量分布偏斜、存在明显的异常值。这些特征叠加在一起使得探索性数据分析EDA成为整个建模流程中最关键的前置环节。很多刚接触这类任务的朋友容易犯一个错误拿到数据直接上模型XGBoost、LightGBM一通跑然后发现线上分数和本地交叉验证差距很大或者模型效果怎么调都上不去。根因往往不在模型本身而在于对数据的理解不够深入。EDA要解决的问题就是在建模之前把数据的脾气摸清楚。哪些特征有缺失、缺失是否有规律、目标变量长什么样、哪些特征和价格强相关、是否存在数据泄漏、训练集和测试集的分布是否一致——这些问题如果不提前搞清楚后面调参就是盲人摸象。这篇文章面向的读者是正在做DataWhale组队学习Task2的同学、刚入门数据挖掘想找一个完整EDA实战案例的初学者、以及做过一些Kaggle或天池比赛但EDA环节总是草草了事想补齐短板的进阶者。我会按照一个完整的EDA工作流来展开从数据概览、缺失值分析、目标变量分布、特征与目标的关系、特征之间的交互、异常值检测一直到为后续特征工程提供方向性建议。每一步都会说清楚为什么这么做以及实际中怎么做代码和参数选择都会给出理由。注意本文中的代码基于pandas、numpy、matplotlib、seaborn这几个核心库不依赖任何自动化EDA工具。原因很简单——自动化工具如pandas-profiling虽然方便但它不会告诉你“这个缺失值背后的业务含义是什么”而人工逐项分析的过程才是真正建立数据直觉的过程。2. 数据初探加载、形状与字段类型解析2.1 数据加载与基础信息速览拿到数据的第一步永远是先看一眼它长什么样。天池二手车数据集通常包含训练集和测试集两个文件训练集带有price字段目标变量测试集没有。加载之后我习惯用一套“组合拳”快速建立对数据的整体印象import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns # 加载数据 train pd.read_csv(used_car_train.csv, sep ) test pd.read_csv(used_car_test.csv, sep ) # 基础信息 print(训练集形状:, train.shape) print(测试集形状:, test.shape) print(\n字段类型分布:) print(train.dtypes.value_counts()) print(\n前5行预览:) print(train.head())这套操作看起来简单但信息量很大。train.shape告诉你样本量和特征数量通常这个数据集训练集有15万条左右、测试集5万条左右特征数量在30个上下不含目标变量。dtypes.value_counts()让你快速区分哪些是数值型、哪些是object类型通常是类别型字段被读成了字符串。head()则是用肉眼扫一遍数据看看有没有明显的格式问题。我特别建议在head()之后加一步train.sample(10, random_state42)。head()只能看到前10行如果数据是按某种顺序排列的比如按时间排序前几行可能不具代表性。随机采样10行能让你看到更真实的数据面貌。2.2 字段含义与类型划分这个数据集的字段大致可以分为几类理解每个字段的业务含义对后续分析至关重要字段类别典型字段数据类型业务含义目标变量price数值型交易价格预测目标车辆基本属性brand, bodyType, fuelType, gearbox类别型品牌、车身类型、燃油类型、变速箱使用属性kilometer, notRepairedDamage数值/类别行驶公里数、是否有未修复损伤动力属性power, engineDisplacement数值型功率、排量时间属性regDate, creatDate数值型编码日期注册日期、创建日期匿名特征v_0 ~ v_14数值型经过脱敏处理的特征这里有一个关键点regDate和creatDate虽然以数值形式存储如20160101但它们本质上是时间信息。在EDA阶段我建议把它们转换成真正的日期格式然后提取出“车龄”这个衍生特征来看分布。车龄对二手车价格的影响几乎是最直接的——一辆2010年的车和一辆2018年的车即使其他条件相同价格差异也会非常大。# 日期转换与车龄计算 train[regDate] pd.to_datetime(train[regDate], format%Y%m%d, errorscoerce) train[creatDate] pd.to_datetime(train[creatDate], format%Y%m%d, errorscoerce) train[car_age] (train[creatDate] - train[regDate]).dt.days / 365 print(车龄统计:) print(train[car_age].describe())实操心得errorscoerce这个参数一定要加。实际数据中经常存在日期格式不合法的情况比如月份为00不加这个参数会直接报错中断。加了之后不合法的日期会变成NaT后续可以单独处理。2.3 训练集与测试集分布一致性检查这一步很多人会忽略但它极其重要。如果训练集和测试集在某些关键特征上的分布差异很大那么本地验证集的表现就无法代表线上表现。检查方法很直接把训练集和测试集的特征分布画在一起对比。# 检查关键数值特征的分布一致性 fig, axes plt.subplots(2, 3, figsize(18, 10)) num_cols [power, kilometer, car_age, v_0, v_3, v_8] for idx, col in enumerate(num_cols): ax axes[idx // 3][idx % 3] if col in train.columns and col in test.columns: ax.hist(train[col].dropna(), bins50, alpha0.5, labelTrain, densityTrue) ax.hist(test[col].dropna(), bins50, alpha0.5, labelTest, densityTrue) ax.set_title(col) ax.legend() plt.tight_layout() plt.show()如果两个分布基本重合说明数据划分是随机的可以放心使用交叉验证。如果差异明显就需要考虑是否存在时间切分或其他划分逻辑验证策略也要相应调整。根据我的经验这个数据集的训练集和测试集分布基本一致可以放心做随机K折交叉验证。3. 缺失值分析与处理策略制定3.1 缺失值全景扫描缺失值分析是EDA中最能体现“业务理解”的环节。同样是缺失背后的原因可能完全不同有的是随机缺失MCAR有的是因为某个条件不满足而缺失MAR还有的是因为字段本身对某些样本不适用MNAR。处理策略也完全不同。# 缺失值统计 missing train.isnull().sum() missing_pct (missing / len(train)) * 100 missing_df pd.DataFrame({ 缺失数量: missing, 缺失比例(%): missing_pct }).sort_values(缺失比例(%), ascendingFalse) print(missing_df[missing_df[缺失数量] 0])在这个数据集中通常notRepairedDamage、fuelType、gearbox、bodyType、model这几个字段会有缺失。缺失比例从千分之几到百分之十几不等。3.2 缺失值的可视化与模式分析光看数字不够我习惯用missingno库或者自己写一个缺失矩阵图来看缺失的模式# 缺失值矩阵可视化 plt.figure(figsize(12, 6)) sns.heatmap(train[missing_df[missing_df[缺失数量]0].index].isnull(), cbarFalse, yticklabelsFalse, cmapviridis) plt.title(缺失值分布矩阵) plt.show()这张图能告诉你一个关键信息缺失是随机的散点还是成片的区块。如果是散点说明是随机缺失填充策略相对简单如果是区块状说明某些样本整体缺失多个字段可能需要考虑是否删除这些样本或者它们代表了某种特殊的车辆类型。3.3 缺失值填充策略与理由针对这个数据集我的填充策略如下字段缺失比例填充策略理由notRepairedDamage较低填充为众数或单独一类该字段本身取值少缺失可能代表“未知”fuelType中等填充为众数燃油类型分布集中众数填充偏差小gearbox中等填充为众数同上bodyType中等填充为众数或单独一类车身类型分布较分散可考虑单独编码model较高填充为-1或单独一类缺失比例高众数填充可能引入偏差# 填充示例 for col in [notRepairedDamage, fuelType, gearbox, bodyType]: mode_val train[col].mode()[0] train[col] train[col].fillna(mode_val) test[col] test[col].fillna(mode_val) # model字段缺失比例高单独标记 train[model] train[model].fillna(-1) test[model] test[model].fillna(-1)注意填充操作必须在训练集和测试集上使用相同的填充值通常来自训练集的统计量否则会造成数据泄漏。这是一个非常容易犯的错误尤其是在做交叉验证的时候。3.4 缺失值背后的业务思考我想特别聊一下notRepairedDamage这个字段。它的取值通常是0无未修复损伤和1有未修复损伤缺失可能意味着卖家没有提供这项信息。从业务角度看“未提供”本身可能就是一个信号——要么是卖家不重视要么是车况有问题不想说。所以除了众数填充另一种思路是把缺失单独作为一个类别比如编码为2让模型自己去学习这个“未知”类别的价格影响。我在实际比赛中试过这两种方式差异不大但如果缺失比例较高单独编码通常更稳妥。4. 目标变量price的深度剖析4.1 分布形态与偏态检验目标变量的分布直接决定了你后续要不要做对数变换、用什么损失函数、怎么设计验证指标。先看基本统计量和分布图# 目标变量统计 print(train[price].describe()) print(\n偏度:, train[price].skew()) print(峰度:, train[price].kurtosis()) # 分布图 fig, axes plt.subplots(1, 3, figsize(18, 5)) axes[0].hist(train[price], bins100, edgecolorblack) axes[0].set_title(price原始分布) axes[1].hist(np.log1p(train[price]), bins100, edgecolorblack) axes[1].set_title(log(1price)分布) axes[2].boxplot(train[price]) axes[2].set_title(price箱线图) plt.tight_layout() plt.show()通常你会发现原始price分布严重右偏偏度远大于1大部分车辆价格集中在较低区间少数高价车拖出了长尾。取对数之后分布会接近正态。这个发现直接影响后续建模策略——如果使用线性模型或神经网络对price取对数几乎是必须的如果使用树模型对数变换的影响相对小一些但仍然有助于稳定梯度。4.2 异常值识别与处理决策价格数据中几乎必然存在异常值。识别方法我常用两种方法一IQR法则Q1 train[price].quantile(0.25) Q3 train[price].quantile(0.75) IQR Q3 - Q1 lower_bound Q1 - 1.5 * IQR upper_bound Q3 1.5 * IQR outliers train[(train[price] lower_bound) | (train[price] upper_bound)] print(fIQR法则下异常值数量: {len(outliers)}) print(f占比: {len(outliers)/len(train)*100:.2f}%)方法二基于分位数的截断lower train[price].quantile(0.01) upper train[price].quantile(0.99) print(f1%分位数: {lower}, 99%分位数: {upper})实操心得对于这个数据集我不建议直接删除异常值。原因有两点第一高价车是真实存在的删除它们会让模型在预测高价车时表现很差第二天池比赛的测试集中同样可能存在高价车删除训练集中的高价样本会导致模型系统性低估。更稳妥的做法是保留异常值但在特征工程阶段通过稳健缩放RobustScaler或对数变换来降低它们的影响。4.3 价格的分组对比分析把price按照关键类别特征分组看组间差异这是理解“哪些特征对价格影响大”的最直接方式# 按品牌分组看价格分布 brand_price train.groupby(brand)[price].agg([mean, median, count]) brand_price brand_price.sort_values(median, ascendingFalse) print(brand_price.head(20)) # 可视化 plt.figure(figsize(14, 6)) sns.boxplot(xbrand, yprice, datatrain[train[brand].isin(brand_price.head(15).index)]) plt.xticks(rotation45) plt.title(Top15品牌的价格分布) plt.show()从这张图你能看出哪些品牌是高价品牌、哪些是低价品牌、品牌内部的方差有多大。如果某个品牌内部方差极大说明该品牌下车型跨度大单纯用品牌编码可能不够需要考虑品牌与其他特征的交互。5. 特征与目标变量的关系挖掘5.1 数值特征的相关性分析数值特征与price的相关性是最容易量化的。我通常同时看Pearson相关系数和Spearman相关系数# 选择数值型特征 num_features train.select_dtypes(include[np.number]).columns.tolist() num_features.remove(price) # 计算相关系数 pearson_corr train[num_features [price]].corr(methodpearson)[price].drop(price) spearman_corr train[num_features [price]].corr(methodspearman)[price].drop(price) corr_df pd.DataFrame({ Pearson: pearson_corr, Spearman: spearman_corr }).sort_values(Pearson, ascendingFalse) print(corr_df)Pearson衡量线性相关Spearman衡量单调相关。如果某个特征的Spearman相关系数明显高于Pearson说明它和price的关系是非线性的比如U型或指数型后续可以考虑分箱或多项式变换。# 相关性热力图 plt.figure(figsize(16, 12)) corr_matrix train[num_features [price]].corr() mask np.triu(np.ones_like(corr_matrix, dtypebool)) sns.heatmap(corr_matrix, maskmask, annotFalse, cmapRdBu_r, center0) plt.title(特征相关性热力图) plt.show()热力图的另一个作用是发现特征之间的共线性。如果两个特征相关系数超过0.9说明它们携带的信息高度重叠可以考虑删除其中一个或做PCA降维。在这个数据集中匿名特征v_0到v_14之间可能存在较强的相关性需要重点关注。5.2 类别特征的分组统计对于类别特征我习惯用分组统计可视化来观察cat_features [bodyType, fuelType, gearbox, notRepairedDamage] fig, axes plt.subplots(2, 2, figsize(16, 12)) for idx, col in enumerate(cat_features): ax axes[idx // 2][idx % 2] train.groupby(col)[price].median().plot(kindbar, axax) ax.set_title(f{col} vs 价格中位数) ax.set_ylabel(价格中位数) plt.tight_layout() plt.show()这种图能直观告诉你某个类别值对应的价格中位数是明显偏高还是偏低。比如notRepairedDamage1有未修复损伤的车价格中位数通常明显低于0的车。这个信息在特征工程阶段可以用来做目标编码Target Encoding。5.3 匿名特征v_0~v_14的探索匿名特征是这类比赛中最有意思也最让人头疼的部分。它们经过了脱敏处理你不知道具体含义但可以通过分布和相关性来推测它们的性质v_cols [fv_{i} for i in range(15)] # 匿名特征的分布 fig, axes plt.subplots(3, 5, figsize(20, 12)) for idx, col in enumerate(v_cols): ax axes[idx // 5][idx % 5] ax.hist(train[col].dropna(), bins50) ax.set_title(col) plt.tight_layout() plt.show()观察要点如果某个v特征的分布是离散的只有几个值它可能是类别型特征被编码成了数值如果分布连续且接近正态可能是某种统计量如果分布严重偏斜可能需要变换。我还会计算每个v特征与price的互信息Mutual Information互信息能捕捉非线性关系from sklearn.feature_selection import mutual_info_regression mi_scores mutual_info_regression( train[v_cols].fillna(0), train[price], random_state42 ) mi_df pd.DataFrame({特征: v_cols, 互信息: mi_scores}).sort_values(互信息, ascendingFalse) print(mi_df)互信息排名靠前的v特征即使Pearson相关系数不高也值得在建模时保留。6. 特征交互与组合效应分析6.1 车龄与价格的交互车龄和价格的关系几乎肯定不是线性的。一辆1年车和2年车的价格差异可能远大于10年车和11年车的差异。我习惯用分箱折线图来观察# 车龄分箱 train[age_bin] pd.cut(train[car_age], bins20) age_price train.groupby(age_bin)[price].agg([mean, median, count]) plt.figure(figsize(14, 6)) plt.plot(range(len(age_price)), age_price[median], markero) plt.xticks(range(len(age_price)), [str(x) for x in age_price.index], rotation45) plt.xlabel(车龄区间) plt.ylabel(价格中位数) plt.title(车龄与价格的关系) plt.show()如果曲线呈现明显的非线性比如先快速下降后趋于平缓那么在特征工程阶段可以考虑对车龄做分箱离散化或者添加车龄的平方项。6.2 公里数与车龄的联合效应公里数和车龄通常高度相关开得越久公里数越高但它们对价格的影响可能不是简单叠加的。我常用双变量分组来看# 车龄和公里数的联合分组 train[km_bin] pd.qcut(train[kilometer], q5, duplicatesdrop) pivot train.pivot_table(valuesprice, indexage_bin, columnskm_bin, aggfuncmedian) plt.figure(figsize(14, 8)) sns.heatmap(pivot, annotTrue, fmt.0f, cmapYlOrRd) plt.title(车龄与公里数对价格中位数的联合影响) plt.show()这张热力图能告诉你在相同车龄下公里数对价格的影响有多大在相同公里数下车龄的影响有多大。如果两个维度都有明显影响那么它们的组合特征比如车龄×公里数可能是一个强特征。6.3 品牌与价格的交互不同品牌对车龄的敏感度可能不同。豪华品牌的车可能前几年贬值快但之后趋于稳定经济型品牌可能贬值曲线更平缓。这种交互效应可以通过分品牌绘制车龄-价格曲线来观察top_brands train[brand].value_counts().head(5).index plt.figure(figsize(14, 6)) for brand in top_brands: subset train[train[brand] brand] brand_age_price subset.groupby(age_bin)[price].median() plt.plot(range(len(brand_age_price)), brand_age_price, markero, labelfBrand {brand}) plt.legend() plt.xlabel(车龄区间) plt.ylabel(价格中位数) plt.title(不同品牌的车龄-价格曲线) plt.show()如果不同品牌的曲线形状差异明显说明品牌和车龄之间存在交互效应在特征工程阶段可以考虑添加品牌与车龄的交叉特征。7. 常见问题与排查技巧实录7.1 EDA阶段的高频问题速查表问题现象可能原因排查方法解决思路日期转换报错日期格式不合法检查regDate的取值分布加errorscoerce统计NaT数量相关系数矩阵出现NaN某列全为空或常数列检查各列方差删除常数列或填充后再计算分组统计结果异常类别编码混乱检查类别字段的唯一值统一编码处理拼写不一致分布图看不出差异量纲差异过大检查特征取值范围标准化后再可视化热力图显示大量强相关特征冗余计算相关系数矩阵考虑删除或PCA降维训练集测试集分布不一致划分方式非随机对比关键特征分布调整验证策略7.2 独家避坑技巧技巧一先采样再画图。15万条数据直接画散点图会卡到怀疑人生而且点重叠严重看不出规律。我通常先train.sample(5000)再画散点图效果反而更清晰。技巧二类别特征先看基数。在做任何编码之前先看每个类别特征有多少个唯一值。如果某个特征的唯一值超过50个比如model字段可能有上千个值直接做One-Hot编码会导致维度爆炸应该考虑目标编码或频率编码。for col in train.select_dtypes(includeobject).columns: print(f{col}: {train[col].nunique()}个唯一值)技巧三价格分析优先看中位数。由于price分布严重偏斜均值容易被高价车拉高中位数更能代表“典型车辆”的价格水平。在做分组对比时我通常同时看均值和中位数如果两者差异很大说明组内分布偏斜严重。技巧四保存EDA中间结果。每次做完一个分析步骤把关键统计量保存到CSV或字典中。后续写报告或做特征工程时可以直接引用不用重新跑一遍。eda_results {} eda_results[price_median] train[price].median() eda_results[price_skew] train[price].skew() eda_results[missing_pct] missing_pct.to_dict() # 保存 import json with open(eda_results.json, w) as f: json.dump(eda_results, f, indent2, defaultstr)7.3 关于EDA时间分配的体会我见过很多人在EDA阶段花的时间太少急着进入建模环节结果后面反复回头补分析。我的经验是EDA的时间应该占到整个项目时间的30%到40%。具体分配上数据概览和缺失值分析占20%目标变量分析占20%特征与目标关系分析占40%特征交互分析占20%。这个比例不是死的但核心原则是——在特征与目标关系分析上花最多时间因为这部分直接决定了你后续特征工程的方向。8. 从EDA到特征工程的衔接建议EDA的最终产出不是一堆图而是一份特征工程行动清单。基于前面的分析我通常会整理出这样一份清单需要做对数变换的特征price如果使用线性模型、power如果分布偏斜严重需要做分箱的特征car_age、kilometer如果与price呈明显非线性关系需要做目标编码的特征brand、model、bodyType高基数类别特征需要做交叉的特征brand×car_age、car_age×kilometer如果交互效应明显需要删除或降维的特征相关系数超过0.95的冗余特征需要特殊处理的样本price极端高或极端低的样本保留但考虑稳健缩放这份清单就是Task3及后续任务的起点。EDA做得越扎实特征工程就越有方向感模型效果的天花板也越高。我在实际比赛中最大的体会就是一个经过充分EDA指导的特征工程方案比盲目调参带来的提升要大得多。模型选择决定下限特征工程决定上限而EDA决定特征工程的方向。