跳到主要内容

探索性数据分析(EDA)

探索性数据分析(Exploratory Data Analysis,EDA)是数据分析流程中的关键步骤,通过统计图表和数值摘要来理解数据的结构、发现模式、检测异常,为后续的建模和分析奠定基础。本章将系统介绍 EDA 的方法和实践。

EDA 概述

为什么需要 EDA?

在正式分析之前,我们需要:

  1. 理解数据结构:了解数据的基本特征和类型
  2. 发现数据质量问题:检测缺失值、异常值、重复数据
  3. 探索变量关系:发现变量之间的相关性和模式
  4. 生成分析假设:为后续建模提供方向

EDA 流程

一个完整的 EDA 通常包括以下步骤:

  1. 数据概览:了解数据规模、变量类型
  2. 单变量分析:分析每个变量的分布
  3. 双变量分析:分析两个变量之间的关系
  4. 多变量分析:分析多个变量之间的复杂关系
  5. 特征工程:基于发现创建新特征

数据概览

加载数据

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

# 设置显示选项
pd.set_option('display.max_columns', None)
pd.set_option('display.width', None)

# 设置图表样式
plt.style.use('seaborn-v0_8-whitegrid')
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
plt.rcParams['axes.unicode_minus'] = False

# 创建示例数据集(模拟电商用户数据)
np.random.seed(42)
n = 1000

df = pd.DataFrame({
'user_id': range(1, n + 1),
'age': np.random.randint(18, 70, n),
'gender': np.random.choice(['Male', 'Female'], n),
'city': np.random.choice(['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 'Other'], n),
'education': np.random.choice(['High School', 'Bachelor', 'Master', 'PhD'], n),
'income': np.random.normal(15000, 5000, n),
'registration_date': pd.date_range('2020-01-01', periods=n, freq='D'),
'total_purchases': np.random.poisson(5, n),
'total_spent': np.random.gamma(2, 500, n),
'last_login_days': np.random.randint(1, 100, n),
'is_active': np.random.choice([0, 1], n, p=[0.3, 0.7])
})

# 引入一些数据质量问题
# 添加缺失值
df.loc[df.sample(frac=0.05).index, 'income'] = np.nan
df.loc[df.sample(frac=0.03).index, 'education'] = np.nan

# 添加异常值
df.loc[df.sample(frac=0.01).index, 'income'] = df['income'].max() * 5
df.loc[df.sample(frac=0.01).index, 'total_spent'] = df['total_spent'].max() * 10

print("数据加载完成!")

基本信息查看

# 查看前几行
print("数据前5行:")
print(df.head())

# 查看数据形状
print(f"\n数据维度: {df.shape[0]} 行 x {df.shape[1]} 列")

# 查看数据类型
print("\n数据类型:")
print(df.dtypes)

# 查看基本信息
print("\n数据信息摘要:")
print(df.info())

# 查看数值列统计
print("\n数值统计摘要:")
print(df.describe())

数据质量检查

def check_data_quality(df):
"""数据质量检查函数"""
print("=" * 50)
print("数据质量报告")
print("=" * 50)

# 1. 缺失值检查
print("\n【缺失值统计】")
missing = df.isnull().sum()
missing_pct = (missing / len(df) * 100).round(2)
missing_df = pd.DataFrame({
'缺失数量': missing,
'缺失比例(%)': missing_pct
})
missing_df = missing_df[missing_df['缺失数量'] > 0]
if len(missing_df) > 0:
print(missing_df.sort_values('缺失数量', ascending=False))
else:
print("没有发现缺失值")

# 2. 重复值检查
print(f"\n【重复行数量】: {df.duplicated().sum()}")

# 3. 唯一值检查
print("\n【各列唯一值数量】")
for col in df.columns:
unique_count = df[col].nunique()
unique_pct = (unique_count / len(df) * 100). round(1)
print(f"{col}: {unique_count} ({unique_pct}%)")

# 4. 数据类型检查
print("\n【数据类型分布】")
print(df.dtypes.value_counts())

check_data_quality(df)

单变量分析

数值变量分析

def analyze_numeric(df, col):
"""分析单个数值变量"""
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# 直方图
axes[0].hist(df[col].dropna(), bins=30, edgecolor='white', alpha=0.7)
axes[0].set_title(f'{col} 分布直方图')
axes[0].set_xlabel(col)
axes[0].set_ylabel('频数')

# 箱线图
axes[1].boxplot(df[col].dropna(), vert=True)
axes[1].set_title(f'{col} 箱线图')
axes[1].set_ylabel(col)

# KDE图
df[col].plot(kind='kde', ax=axes[2])
axes[2].set_title(f'{col} 核密度估计')
axes[2].set_xlabel(col)

plt.tight_layout()
plt.show()

# 统计摘要
print(f"\n{col} 统计摘要:")
print(df[col].describe())
print(f"偏度: {df[col].skew():.2f}")
print(f"峰度: {df[col].kurtosis():.2f}")

# 分析收入
analyze_numeric(df, 'income')

# 分析年龄
analyze_numeric(df, 'age')

# 分析消费金额
analyze_numeric(df, 'total_spent')

分类变量分析

def analyze_categorical(df, col):
"""分析单个分类变量"""
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# 计数条形图
value_counts = df[col].value_counts()
axes[0].bar(value_counts.index, value_counts.values, edgecolor='white')
axes[0].set_title(f'{col} 分布')
axes[0].set_xlabel(col)
axes[0].set_ylabel('数量')
axes[0].tick_params(axis='x', rotation=45)

# 添加数值标签
for i, v in enumerate(value_counts.values):
axes[0].text(i, v + 5, str(v), ha='center', fontsize=9)

# 饼图
axes[1].pie(value_counts.values, labels=value_counts.index, autopct='%1.1f%%',
startangle=90)
axes[1].set_title(f'{col} 比例')

plt.tight_layout()
plt.show()

# 统计摘要
print(f"\n{col} 分布:")
print(value_counts)
print(f"\n比例 (%):")
print((value_counts / len(df) * 100).round(2))

# 分析性别
analyze_categorical(df, 'gender')

# 分析城市
analyze_categorical(df, 'city')

# 分析教育程度
analyze_categorical(df, 'education')

批量单变量分析

def univariate_analysis(df):
"""批量单变量分析"""
numeric_cols = df.select_dtypes(include=[np.number]).columns
categorical_cols = df.select_dtypes(include=['object', 'category']).columns

# 数值变量统计
print("=" * 60)
print("数值变量统计")
print("=" * 60)

stats_df = pd.DataFrame()
for col in numeric_cols:
stats_df[col] = [
df[col].count(),
df[col].isnull().sum(),
df[col].mean(),
df[col].median(),
df[col].std(),
df[col].min(),
df[col].quantile(0.25),
df[col].quantile(0.75),
df[col].max(),
df[col].skew(),
df[col].kurtosis()
]

stats_df.index = ['计数', '缺失值', '均值', '中位数', '标准差',
'最小值', '25%分位', '75%分位', '最大值', '偏度', '峰度']
print(stats_df.T.round(2))

# 分类变量统计
print("\n" + "=" * 60)
print("分类变量统计")
print("=" * 60)

for col in categorical_cols:
print(f"\n{col}:")
print(df[col].value_counts())

univariate_analysis(df)

双变量分析

数值 vs 数值

def analyze_numeric_vs_numeric(df, col1, col2):
"""分析两个数值变量之间的关系"""
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# 散点图
axes[0].scatter(df[col1], df[col2], alpha=0.5)
axes[0].set_xlabel(col1)
axes[0].set_ylabel(col2)
axes[0].set_title(f'{col1} vs {col2}')

# 添加回归线
z = np.polyfit(df[col1].dropna(), df[col2].dropna(), 1)
p = np.poly1d(z)
x_line = np.linspace(df[col1].min(), df[col1].max(), 100)
axes[0].plot(x_line, p(x_line), 'r--', linewidth=2, label='回归线')
axes[0].legend()

# 六边形图(适合大数据)
axes[1].hexbin(df[col1], df[col2], gridsize=20, cmap='Blues')
axes[1].set_xlabel(col1)
axes[1].set_ylabel(col2)
axes[1].set_title('六边形图')

# KDE等高线图
sns.kdeplot(data=df, x=col1, y=col2, ax=axes[2], fill=True, cmap='Blues')
axes[2].set_title('密度等高线')

plt.tight_layout()
plt.show()

# 相关系数
corr = df[[col1, col2]].corr().iloc[0, 1]
print(f"皮尔逊相关系数: {corr:.3f}")

# 年龄 vs 收入
analyze_numeric_vs_numeric(df, 'age', 'income')

# 收入 vs 消费金额
analyze_numeric_vs_numeric(df, 'income', 'total_spent')

数值 vs 分类

def analyze_numeric_vs_categorical(df, num_col, cat_col):
"""分析数值变量与分类变量的关系"""
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# 分组箱线图
categories = df[cat_col].unique()
data_by_category = [df[df[cat_col] == cat][num_col].dropna() for cat in categories]
axes[0].boxplot(data_by_category, labels=categories)
axes[0].set_xlabel(cat_col)
axes[0].set_ylabel(num_col)
axes[0].set_title(f'{num_col} 按不同 {cat_col}')
axes[0].tick_params(axis='x', rotation=45)

# 分组小提琴图
sns.violinplot(data=df, x=cat_col, y=num_col, ax=axes[1])
axes[1].set_title('小提琴图')
axes[1].tick_params(axis='x', rotation=45)

# 分组均值条形图
grouped_mean = df.groupby(cat_col)[num_col].mean()
axes[2].bar(grouped_mean.index, grouped_mean.values, edgecolor='white')
axes[2].set_xlabel(cat_col)
axes[2].set_ylabel(f'{num_col} 均值')
axes[2].set_title(f'{num_col} 均值比较')
axes[2].tick_params(axis='x', rotation=45)

# 添加误差线
grouped_std = df.groupby(cat_col)[num_col].std()
axes[2].errorbar(grouped_mean.index, grouped_mean.values,
yerr=grouped_std.values, fmt='none', c='black', capsize=5)

plt.tight_layout()
plt.show()

# 统计摘要
print(f"\n{num_col}{cat_col} 分组统计:")
print(df.groupby(cat_col)[num_col].agg(['count', 'mean', 'std', 'min', 'max']).round(2))

# 收入 vs 性别
analyze_numeric_vs_categorical(df, 'income', 'gender')

# 收入 vs 城市
analyze_numeric_vs_categorical(df, 'income', 'city')

# 消费金额 vs 教育程度
analyze_numeric_vs_categorical(df, 'total_spent', 'education')

分类 vs 分类

def analyze_categorical_vs_categorical(df, col1, col2):
"""分析两个分类变量之间的关系"""
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# 交叉表
cross = pd.crosstab(df[col1], df[col2])

# 堆叠条形图
cross.plot(kind='bar', stacked=True, ax=axes[0])
axes[0].set_xlabel(col1)
axes[0].set_ylabel('数量')
axes[0].set_title(f'{col1} vs {col2}')
axes[0].tick_params(axis='x', rotation=45)
axes[0].legend(title=col2, bbox_to_anchor=(1.02, 1), loc='upper left')

# 比例条形图
cross_pct = cross.div(cross.sum(axis=1), axis=0) * 100
cross_pct.plot(kind='bar', stacked=True, ax=axes[1])
axes[1].set_xlabel(col1)
axes[1].set_ylabel('比例 (%)')
axes[1].set_title('比例分布')
axes[1].tick_params(axis='x', rotation=45)
axes[1].legend(title=col2, bbox_to_anchor=(1.02, 1), loc='upper left')

plt.tight_layout()
plt.show()

# 交叉表
print(f"\n交叉表 ({col1} vs {col2}):")
print(cross)

# 卡方检验
from scipy.stats import chi2_contingency
chi2, p, dof, expected = chi2_contingency(cross)
print(f"\n卡方检验: chi2={chi2:.2f}, p-value={p:.4f}")

# 性别 vs 城市分布
analyze_categorical_vs_categorical(df, 'gender', 'city')

# 教育程度 vs 是否活跃
analyze_categorical_vs_categorical(df, 'education', 'is_active')

多变量分析

相关系数矩阵

def correlation_analysis(df):
"""相关系数分析"""
# 选择数值列
numeric_df = df.select_dtypes(include=[np.number])

# 计算相关系数矩阵
corr_matrix = numeric_df.corr()

# 绘制热力图
plt.figure(figsize=(10, 8))
sns.heatmap(corr_matrix, annot=True, cmap='RdYlGn', center=0,
fmt='.2f', square=True, linewidths=0.5)
plt.title('相关系数矩阵')
plt.tight_layout()
plt.show()

# 找出高相关性的变量对
print("\n高相关性变量对 (|r| > 0.5):")
for i in range(len(corr_matrix.columns)):
for j in range(i+1, len(corr_matrix.columns)):
if abs(corr_matrix.iloc[i, j]) > 0.5:
print(f"{corr_matrix.columns[i]} - {corr_matrix.columns[j]}: {corr_matrix.iloc[i, j]:.3f}")

return corr_matrix

corr_matrix = correlation_analysis(df)

配对图

def pairplot_analysis(df, cols, hue_col=None):
"""配对图分析"""
# 选择指定列
plot_df = df[cols].copy()
if hue_col:
plot_df[hue_col] = df[hue_col]

# 绘制配对图
g = sns.pairplot(plot_df, hue=hue_col, height=2.5)
g.fig.suptitle('配对关系图', y=1.02)
plt.show()

# 选择关键数值列
numeric_cols = ['age', 'income', 'total_purchases', 'total_spent', 'last_login_days']
pairplot_analysis(df, numeric_cols, hue_col='is_active')

分组分析

def group_analysis(df, group_cols, value_col):
"""分组聚合分析"""
# 多列分组
grouped = df.groupby(group_cols)[value_col].agg(['count', 'mean', 'std', 'min', 'max'])
grouped = grouped.round(2)

print(f"\n按 {group_cols} 分组的 {value_col} 统计:")
print(grouped)

return grouped

# 按城市和性别分组分析收入
group_result = group_analysis(df, ['city', 'gender'], 'income')

# 可视化
pivot_result = group_result['mean'].unstack()
pivot_result.plot(kind='bar', figsize=(10, 6), edgecolor='white')
plt.title('各城市不同性别平均收入')
plt.xlabel('城市')
plt.ylabel('平均收入')
plt.legend(title='性别')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()

异常值检测

统计方法检测

def detect_outliers(df, col, method='iqr'):
"""检测异常值"""
data = df[col].dropna()

if method == 'iqr':
# IQR 方法
Q1 = data.quantile(0.25)
Q3 = data.quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
outliers = df[(df[col] < lower) | (df[col] > upper)]

print(f"\n{col} 异常值检测 (IQR方法):")
print(f"正常范围: [{lower:.2f}, {upper:.2f}]")
print(f"异常值数量: {len(outliers)} ({len(outliers)/len(df)*100:.2f}%)")

elif method == 'zscore':
# Z-Score 方法
from scipy import stats
z_scores = np.abs(stats.zscore(data))
outliers = df[z_scores > 3]

print(f"\n{col} 异常值检测 (Z-Score方法):")
print(f"异常值数量: {len(outliers)} ({len(outliers)/len(df)*100:.2f}%)")

# 可视化
fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# 带异常值标记的箱线图
axes[0].boxplot(data, vert=True)
axes[0].set_title(f'{col} 箱线图')
axes[0].set_ylabel(col)

# 分布直方图(标注异常值范围)
axes[1].hist(data, bins=30, edgecolor='white', alpha=0.7)
if method == 'iqr':
axes[1].axvline(lower, color='red', linestyle='--', label=f'下界: {lower:.2f}')
axes[1].axvline(upper, color='red', linestyle='--', label=f'上界: {upper:.2f}')
axes[1].legend()
axes[1].set_title(f'{col} 分布')

plt.tight_layout()
plt.show()

return outliers

# 检测收入异常值
outliers_income = detect_outliers(df, 'income', method='iqr')

# 检测消费金额异常值
outliers_spent = detect_outliers(df, 'total_spent', method='iqr')

完整 EDA 报告

def generate_eda_report(df, target_col=None):
"""生成完整的 EDA 报告"""
print("=" * 70)
print("探索性数据分析报告")
print("=" * 70)

# 1. 数据概览
print("\n【1. 数据概览】")
print(f"数据维度: {df.shape[0]} 行 x {df.shape[1]} 列")
print(f"内存占用: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")

# 2. 数据类型
print("\n【2. 数据类型】")
print(df.dtypes.value_counts())

# 3. 缺失值
print("\n【3. 缺失值】")
missing = df.isnull().sum()
missing_pct = (missing / len(df) * 100).round(2)
missing_df = pd.DataFrame({'数量': missing, '比例(%)': missing_pct})
missing_df = missing_df[missing_df['数量'] > 0]
if len(missing_df) > 0:
print(missing_df.sort_values('数量', ascending=False))
else:
print("无缺失值")

# 4. 数值变量统计
print("\n【4. 数值变量统计】")
numeric_cols = df.select_dtypes(include=[np.number]).columns
if len(numeric_cols) > 0:
print(df[numeric_cols].describe().T.round(2))

# 5. 分类变量统计
print("\n【5. 分类变量统计】")
cat_cols = df.select_dtypes(include=['object', 'category']).columns
for col in cat_cols:
print(f"\n{col}:")
print(df[col].value_counts().head(10))

# 6. 相关性分析
if len(numeric_cols) > 1:
print("\n【6. 高相关性变量】")
corr = df[numeric_cols].corr()
for i in range(len(corr.columns)):
for j in range(i+1, len(corr.columns)):
if abs(corr.iloc[i, j]) > 0.7:
print(f"{corr.columns[i]} - {corr.columns[j]}: {corr.iloc[i, j]:.3f}")

# 7. 目标变量分析
if target_col and target_col in df.columns:
print(f"\n【7. 目标变量 {target_col} 分析】")
if df[target_col].dtype in [np.number]:
print(f"均值: {df[target_col].mean():.2f}")
print(f"中位数: {df[target_col].median():.2f}")
print(f"标准差: {df[target_col].std():.2f}")
else:
print(df[target_col].value_counts(normalize=True).round(3))

print("\n" + "=" * 70)
print("EDA 报告生成完成")
print("=" * 70)

# 生成报告
generate_eda_report(df, target_col='is_active')

可视化报告

def create_visualization_report(df):
"""创建可视化报告"""
fig = plt.figure(figsize=(20, 16))

# 选择数值列
numeric_cols = df.select_dtypes(include=[np.number]).columns[:4]
cat_cols = df.select_dtypes(include=['object', 'category']).columns[:2]

# 数值变量分布
for i, col in enumerate(numeric_cols):
ax = fig.add_subplot(3, 4, i+1)
df[col].hist(bins=30, ax=ax, edgecolor='white')
ax.set_title(f'{col} 分布')
ax.set_xlabel(col)

# 分类变量分布
for i, col in enumerate(cat_cols):
ax = fig.add_subplot(3, 4, 5+i)
df[col].value_counts().plot(kind='bar', ax=ax, edgecolor='white')
ax.set_title(f'{col} 分布')
ax.tick_params(axis='x', rotation=45)

# 相关性热力图
ax = fig.add_subplot(3, 4, 9)
numeric_df = df.select_dtypes(include=[np.number])
if len(numeric_df.columns) > 1:
sns.heatmap(numeric_df.corr(), annot=True, fmt='.2f',
cmap='RdYlGn', center=0, ax=ax)
ax.set_title('相关系数矩阵')

# 箱线图
if len(numeric_cols) > 0:
ax = fig.add_subplot(3, 4, 10)
df[numeric_cols[:4]].boxplot(ax=ax)
ax.set_title('数值变量箱线图')
ax.tick_params(axis='x', rotation=45)

plt.suptitle('EDA 可视化报告', fontsize=16, y=1.02)
plt.tight_layout()
plt.show()

create_visualization_report(df)

小结

本章我们学习了探索性数据分析的完整流程:

  1. 数据概览:数据维度、类型、基本信息
  2. 数据质量检查:缺失值、重复值、异常值
  3. 单变量分析:数值分布、分类分布
  4. 双变量分析:数值与数值、数值与分类、分类与分类
  5. 多变量分析:相关系数、配对图、分组分析
  6. 异常值检测:IQR 方法、Z-Score 方法
  7. EDA 报告:自动化生成分析报告

EDA 是数据分析的基础,通过系统性的探索可以:

  • 发现数据质量问题
  • 理解变量分布特征
  • 发现变量之间的关系
  • 为后续建模提供指导

练习

  1. 选择一个真实数据集,按照 EDA 流程进行完整分析
  2. 编写一个函数,自动检测并处理数据中的异常值
  3. 使用可视化方式展示数据中的缺失值模式

参考资源