news 2026/10/13 14:59:14

二手车价格预测EDA实战:从数据探索到特征工程全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
二手车价格预测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_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决定特征工程的方向。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 14:55:52

SpringBoot构建高并发火车票系统的核心技术解析

1. 项目概述火车票订票系统是典型的在线票务服务平台&#xff0c;采用SpringBoot框架构建后端服务&#xff0c;Java作为主要开发语言。这个系统需要处理高并发访问、保证数据一致性&#xff0c;同时提供友好的用户交互体验。作为交通出行领域的核心应用&#xff0c;这类系统需要…

作者头像 李华
网站建设 2026/10/13 14:54:45

Flink Checkpoint原理与生产调参实战:从Barrier对齐到健康度评估

1. 为什么说Checkpoint不是“定时快照”&#xff0c;而是Flink容错的神经中枢&#xff1f;在某次线上数据处理任务突发OOM导致整个作业重启后&#xff0c;我盯着监控面板上那条断崖式下跌的吞吐量曲线&#xff0c;第一次真正意识到&#xff1a;Checkpoint机制根本不是教科书里轻…

作者头像 李华
网站建设 2026/10/13 14:53:31

挡风被生产线设备全解析:从复合绗缝到裁切包边

挡风被&#xff0c;这个在寒冷季节里为我们提供温暖的小发明&#xff0c;背后其实藏着一条比多数人想象中复杂得多的产业链。市面上几十块钱到一百多块钱的挡风被&#xff0c;在电动车把上一搭、绳子一系&#xff0c;看起来简单&#xff1b;但如果进过车间&#xff0c;看到从卷…

作者头像 李华
网站建设 2026/10/13 14:50:41

NLP秋招面试核心:Attention机制与预训练模型复习指南

秋招季又到了&#xff0c;每年这个时候都有不少准备NLP方向的同学来找我聊面试准备的事。聊来聊去&#xff0c;核心逃不开两座大山&#xff1a;Attention机制和预训练模型。如果你正在准备2021秋招的NLP岗位&#xff0c;或者打算转行做算法工程师&#xff0c;这篇文章就是为你写…

作者头像 李华