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_state=42)。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', errors='coerce') train['creatDate'] = pd.to_datetime(train['creatDate'], format='%Y%m%d', errors='coerce') train['car_age'] = (train['creatDate'] - train['regDate']).dt.days / 365 print('车龄统计:') print(train['car_age'].describe())实操心得:
errors='coerce'这个参数一定要加。实际数据中经常存在日期格式不合法的情况(比如月份为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(), bins=50, alpha=0.5, label='Train', density=True) ax.hist(test[col].dropna(), bins=50, alpha=0.5, label='Test', density=True) 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('缺失比例(%)', ascending=False) 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(), cbar=False, yticklabels=False, cmap='viridis') 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'], bins=100, edgecolor='black') axes[0].set_title('price原始分布') axes[1].hist(np.log1p(train['price']), bins=100, edgecolor='black') axes[1].set_title('log(1+price)分布') 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(f'IQR法则下异常值数量: {len(outliers)}') print(f'占比: {len(outliers)/len(train)*100:.2f}%')方法二:基于分位数的截断
lower = train['price'].quantile(0.01) upper = train['price'].quantile(0.99) print(f'1%分位数: {lower}, 99%分位数: {upper}')实操心得:对于这个数据集,我不建议直接删除异常值。原因有两点:第一,高价车是真实存在的,删除它们会让模型在预测高价车时表现很差;第二,天池比赛的测试集中同样可能存在高价车,删除训练集中的高价样本会导致模型系统性低估。更稳妥的做法是保留异常值,但在特征工程阶段通过稳健缩放(RobustScaler)或对数变换来降低它们的影响。
4.3 价格的分组对比分析
把price按照关键类别特征分组,看组间差异,这是理解“哪些特征对价格影响大”的最直接方式:
# 按品牌分组看价格分布 brand_price = train.groupby('brand')['price'].agg(['mean', 'median', 'count']) brand_price = brand_price.sort_values('median', ascending=False) print(brand_price.head(20)) # 可视化 plt.figure(figsize=(14, 6)) sns.boxplot(x='brand', y='price', data=train[train['brand'].isin(brand_price.head(15).index)]) plt.xticks(rotation=45) 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(method='pearson')['price'].drop('price') spearman_corr = train[num_features + ['price']].corr(method='spearman')['price'].drop('price') corr_df = pd.DataFrame({ 'Pearson': pearson_corr, 'Spearman': spearman_corr }).sort_values('Pearson', ascending=False) 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, dtype=bool)) sns.heatmap(corr_matrix, mask=mask, annot=False, cmap='RdBu_r', center=0) 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(kind='bar', ax=ax) ax.set_title(f'{col} vs 价格中位数') ax.set_ylabel('价格中位数') plt.tight_layout() plt.show()这种图能直观告诉你:某个类别值对应的价格中位数是明显偏高还是偏低。比如notRepairedDamage=1(有未修复损伤)的车,价格中位数通常明显低于=0的车。这个信息在特征工程阶段可以用来做目标编码(Target Encoding)。
5.3 匿名特征v_0~v_14的探索
匿名特征是这类比赛中最有意思也最让人头疼的部分。它们经过了脱敏处理,你不知道具体含义,但可以通过分布和相关性来推测它们的性质:
v_cols = [f'v_{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(), bins=50) 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_state=42 ) mi_df = pd.DataFrame({'特征': v_cols, '互信息': mi_scores}).sort_values('互信息', ascending=False) print(mi_df)互信息排名靠前的v特征,即使Pearson相关系数不高,也值得在建模时保留。
6. 特征交互与组合效应分析
6.1 车龄与价格的交互
车龄和价格的关系几乎肯定不是线性的。一辆1年车和2年车的价格差异,可能远大于10年车和11年车的差异。我习惯用分箱+折线图来观察:
# 车龄分箱 train['age_bin'] = pd.cut(train['car_age'], bins=20) 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'], marker='o') plt.xticks(range(len(age_price)), [str(x) for x in age_price.index], rotation=45) plt.xlabel('车龄区间') plt.ylabel('价格中位数') plt.title('车龄与价格的关系') plt.show()如果曲线呈现明显的非线性(比如先快速下降后趋于平缓),那么在特征工程阶段可以考虑对车龄做分箱离散化,或者添加车龄的平方项。
6.2 公里数与车龄的联合效应
公里数和车龄通常高度相关(开得越久公里数越高),但它们对价格的影响可能不是简单叠加的。我常用双变量分组来看:
# 车龄和公里数的联合分组 train['km_bin'] = pd.qcut(train['kilometer'], q=5, duplicates='drop') pivot = train.pivot_table(values='price', index='age_bin', columns='km_bin', aggfunc='median') plt.figure(figsize=(14, 8)) sns.heatmap(pivot, annot=True, fmt='.0f', cmap='YlOrRd') 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, marker='o', label=f'Brand {brand}') plt.legend() plt.xlabel('车龄区间') plt.ylabel('价格中位数') plt.title('不同品牌的车龄-价格曲线') plt.show()如果不同品牌的曲线形状差异明显,说明品牌和车龄之间存在交互效应,在特征工程阶段可以考虑添加品牌与车龄的交叉特征。
7. 常见问题与排查技巧实录
7.1 EDA阶段的高频问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 日期转换报错 | 日期格式不合法 | 检查regDate的取值分布 | 加errors='coerce',统计NaT数量 |
| 相关系数矩阵出现NaN | 某列全为空或常数列 | 检查各列方差 | 删除常数列或填充后再计算 |
| 分组统计结果异常 | 类别编码混乱 | 检查类别字段的唯一值 | 统一编码,处理拼写不一致 |
| 分布图看不出差异 | 量纲差异过大 | 检查特征取值范围 | 标准化后再可视化 |
| 热力图显示大量强相关 | 特征冗余 | 计算相关系数矩阵 | 考虑删除或PCA降维 |
| 训练集测试集分布不一致 | 划分方式非随机 | 对比关键特征分布 | 调整验证策略 |
7.2 独家避坑技巧
技巧一:先采样再画图。15万条数据直接画散点图会卡到怀疑人生,而且点重叠严重看不出规律。我通常先train.sample(5000)再画散点图,效果反而更清晰。
技巧二:类别特征先看基数。在做任何编码之前,先看每个类别特征有多少个唯一值。如果某个特征的唯一值超过50个(比如model字段可能有上千个值),直接做One-Hot编码会导致维度爆炸,应该考虑目标编码或频率编码。
for col in train.select_dtypes(include='object').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, indent=2, default=str)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决定特征工程的方向。