Python数据分析实战:用Seaborn和Scipy绘制CDF图的5个实用技巧
在数据探索和结果汇报中,直方图和箱线图固然经典,但累积分布函数图(CDF)往往能以一种更直观、更具统计洞察力的方式,揭示数据背后的完整故事。它不只是一个单调递增的曲线,更是理解数据分布形态、比较不同群体、识别异常阈值和评估模型性能的利器。对于需要向非技术背景的决策者解释复杂数据分布,或者需要在多个模型输出间进行精细对比的数据分析师而言,掌握CDF图的进阶绘制技巧,意味着能从图表中挖掘出更深层次、更具说服力的信息。
这篇文章不是另一个简单的“如何画出一条CDF曲线”的教程。我们将直接切入实战,聚焦于Seaborn和Scipy这两个在Python数据分析生态中举足轻重的库,分享五个能立刻提升你图表专业性和分析深度的技巧。无论你是希望让报告图表更具美感,还是需要处理真实世界中不那么“干净”的数据,亦或是想通过CDF进行严谨的统计推断,下面的内容都将提供清晰的路径和可复现的代码。
1. 从混乱到清晰:数据预处理与CDF的稳健性构建
绘制CDF的第一步永远不是plt.plot(),而是审视你的数据。真实世界的数据集常常伴随着缺失值、极端异常值以及非数值型条目,这些都会让最基础的CDF计算函数“卡壳”或产生误导性结果。
1.1 处理缺失值与异常值
Seaborn的ecdfplot()在遇到NaN时会直接报错。一个健壮的工作流始于数据清洗。
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
# 模拟一个包含缺失值和异常值的真实数据集
np.random.seed(42)
data = pd.DataFrame({
'group_A': np.concatenate([np.random.normal(100, 15, 950), [np.nan]*30, [1000, -500]]), # 包含NaN和极端值
'group_B': np.random.exponential(50, 1000)
})
print(f"原始数据形状: {data.shape}")
print(f"Group A 缺失值数量: {data['group_A'].isna().sum()}")
print(f"Group A 描述统计:\n{data['group_A'].describe()}")
# 技巧1:选择性清洗而非全局删除
# 对于CDF,我们通常更关心数据的“主体”分布,对极端异常值敏感
# 方法A:基于分位数过滤(适用于对称分布)
Q1 = data['group_A'].quantile(0.25)
Q3 = data['group_A'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
data_clean_iqr = data[(data['group_A'] >= lower_bound) & (data['group_A'] <= upper_bound)].copy()
data_clean_iqr = data_clean_iqr.dropna(subset=['group_A']) # 移除该列的NaN
# 方法B:基于绝对中位差(MAD)过滤(对异常值更稳健)
from scipy.stats import median_abs_deviation
median_val = data['group_A'].median()
mad = median_abs_deviation(data['group_A'], nan_policy='omit')
# 通常使用3倍MAD作为阈值
data_clean_mad = data[np.abs(data['group_A'] - median_val) / mad <= 3].copy()
data_clean_mad = data_clean_mad.dropna(subset=['group_A'])
print(f"\nIQR法清洗后数据量: {len(data_clean_iqr)}")
print(f"MAD法清洗后数据量: {len(data_clean_mad)}")
注意:选择哪种清洗方法取决于你的分析目标。如果目标是研究包括尾部在内的完整分布(如金融风险中的极端损失),则可能需保留异常值,但需在图中明确标注。如果目标是了解主体数据的典型行为,则过滤是必要的。
1.2 数据转换与CDF
某些数据(如收入、网页访问时长)通常呈重尾分布。直接绘制CDF可能导致曲线在低值区域过于陡峭,在高值区域拉得过长,难以观察细节。此时,对数转换(或其它幂变换)能有效“拉伸”分布。
# 原始数据 - 指数分布,典型的重尾
data_raw = np.random.exponential(scale=100, size=1000)
# 对数据取对数
# 添加一个小常数避免log(0)
data_log = np.log1p(data_raw) # log1p = log(1 + x)
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# 绘制原始数据CDF
sns.ecdfplot(data=data_raw, ax=axes[0])
axes[0].set_title('原始数据CDF (指数分布)')
axes[0].set_xlabel('原始值')
axes[0].grid(True, alpha=0.3)
# 绘制对数转换后数据CDF
sns.ecdfplot(data=data_log, ax=axes[1])
axes[1].set_title('对数转换后数据CDF')
axes[1].set_xlabel('log(1 + 值)')
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
对数转换后的CDF曲线形态更接近我们熟悉的S型,便于比较不同数据集在“尺度”上的差异,这在比较用户活跃度、城市规模等数据时特别有用。
2. 超越基础绘图:Seaborn ecdfplot的深度定制与美学提升
seaborn.ecdfplot() 是绘制经验CDF最快捷的工具,但它的默认输出可能过于“学术化”。要让图表在商业报告或文章中脱颖而出,需要进行深度定制。
2.1 多组数据对比与统计量标注
在A/B测试或不同群体对比中,我们常需要将多条CDF曲线放在一起。ecdfplot的hue参数是天然的多组对比工具,但如何清晰地传达关键统计量的差异?
# 生成模拟的A/B测试数据(例如,新旧版本页面停留时间)
np.random.seed(123)
n_samples = 500
group_a = np.random.gamma(shape=2, scale=30, size=n_samples) # 版本A
group_b = np.random.gamma(shape=2.2, scale=28, size=n_samples) # 版本B (略有改进)
df_ab = pd.DataFrame({
'停留时间(秒)': np.concatenate([group_a, group_b]),
'版本': ['A'] * n_samples + ['B'] * n_samples
})
plt.figure(figsize=(10, 6))
ax = sns.ecdfplot(data=df_ab, x='停留时间(秒)', hue='版本',
palette={'A': '#2E86AB', 'B': '#A23B72'}, # 自定义颜色
linewidth=2.5,
stat='proportion', # 纵轴显示比例而非计数
complementary=False) # 绘制标准CDF,而非1-CDF
# 计算并标注中位数、第90百分位数等关键分位点
for version, color in [('A', '#2E86AB'), ('B', '#A23B72')]:
data = df_ab[df_ab['版本'] == version]['停留时间(秒)'].values
median = np.median(data)
p90 = np.percentile(data, 90)
# 在曲线上标注中位数
# 找到最接近中位数的数据点索引(用于确定y值)
sorted_data = np.sort(data)
cdf_y = np.arange(1, len(sorted_data)+1) / len(sorted_data)
idx_median = np.argmin(np.abs(sorted_data - median))
y_median = cdf_y[idx_median]
ax.plot(median, y_median, 'o', color=color, markersize=10, markeredgecolor='white', markeredgewidth=1.5)
ax.vlines(x=median, ymin=0, ymax=y_median, color=color, linestyle='--', alpha=0.7, linewidth=1)
ax.hlines(y=y_median, xmin=0, xmax=median, color=color, linestyle='--', alpha=0.7, linewidth=1)
# 添加文本标注
ax.text(median+2, y_median+0.02, f'中位数: {median:.1f}s', color=color, fontsize=10, va='bottom')
ax.text(p90+2, 0.88, f'P90 ({version}): {p90:.1f}s', color=color, fontsize=9, alpha=0.8)
# 添加阴影区域表示差异(例如,在某个关键阈值以下的用户比例差异)
threshold = 60 # 假设我们关心60秒以下的用户
for version, color in [('A', '#2E86AB'), ('B', '#A23B72')]:
data = df_ab[df_ab['版本'] == version]['停留时间(秒)'].values
prop_below_threshold = np.mean(data <= threshold)
ax.fill_betweenx(y=[0, prop_below_threshold], x1=0, x2=threshold, color=color, alpha=0.1)
ax.set_title('A/B测试:不同版本用户停留时间累积分布对比', fontsize=14, fontweight='bold', pad=20)
ax.set_ylabel('用户比例 (≤ X秒)', fontsize=12)
ax.set_xlabel('停留时间 (秒)', fontsiz


328

被折叠的 条评论
为什么被折叠?



