1. 为什么你需要学会绘制CDF对比图?

在数据分析的日常工作中,我们常常会遇到这样的困惑:手头有两组甚至多组数据,比如A/B测试中两个版本的用户停留时长、不同营销策略下的销售额、或者不同型号设备产生的噪音数据。我们想知道,这些数据分布到底有什么不同?是A组的整体表现更好,还是B组的波动性更小?仅仅比较平均值或中位数,往往会丢失大量信息,比如极端值的情况、数据分布的“厚尾”或“薄尾”特性。

这时候,累积分布函数图,也就是我们常说的CDF图,就派上大用场了。它就像一个“数据透视镜”,能让你一眼看穿数据分布的全貌和差异。简单来说,CDF图上的每一个点 (x, y) 都告诉你:在整个数据集中,有多大比例(y)的数据点小于或等于某个值(x)。比如,在用户停留时长的CDF图上,横坐标是时长,纵坐标是累积概率。你很容易就能找到“90%的用户停留时长都小于多少秒”这个关键业务指标。

我刚开始做数据分析时,也只会画直方图或密度图。直到有一次,我需要对比三个不同算法在数百次运行中的耗时分布,直方图堆在一起密密麻麻,根本看不出谁优谁劣。一个资深同事建议我:“试试把CDF图画在一起对比。” 我照做了,结果豁然开朗——三条曲线清晰地展示了哪个算法不仅平均耗时短,而且耗时分布更集中、更稳定。从那以后,CDF对比图就成了我工具箱里的常客。

那么,在Python里怎么快速画出专业又好看的多组数据CDF对比图呢?答案就是结合 SeabornSciPy 这两个库。Seaborn让你用极简的代码画出美观的统计图形,而SciPy则提供了强大的科学计算和统计函数,让你能进行更精细的控制和计算。接下来,我就手把手带你从零开始,搞定这个技能。

2. 环境准备与数据理解

2.1 安装必要的库

工欲善其事,必先利其器。我们首先需要确保环境里安装了必要的Python库。如果你使用Anaconda,大部分库已经预装了。如果没有,打开你的终端或命令提示符,用pip安装即可。我强烈建议创建一个独立的虚拟环境来做数据分析,避免包版本冲突。

# 使用pip安装核心库
pip install numpy pandas matplotlib seaborn scipy jupyter

# 如果你使用Jupyter Notebook进行交互式分析(强烈推荐)
pip install notebook

简单解释一下这几个库的分工:

  • NumPy:Python科学计算的基础,提供高效的数组操作。我们的数据通常以NumPy数组的形式存在。
  • Pandas:数据处理和分析的利器,它的DataFrame结构非常适合存储和操作表格型数据。
  • Matplotlib:Python绘图的基石,Seaborn是基于它构建的,我们最终图形的渲染离不开它。
  • Seaborn:基于Matplotlib的统计图形库,提供了高级接口和美观的默认样式,绘制CDF图非常方便。
  • SciPy:包含大量科学计算工具,其中的stats模块为我们提供了丰富的统计分布和函数。
  • Jupyter Notebook:交互式编程环境,方便我们一步步执行代码并即时查看图表结果。

2.2 理解你的数据:从模拟到真实

在学习阶段,我们可以用NumPy快速生成一些模拟数据来练手。这能帮助我们理解CDF图的形态。但在实际项目中,你的数据可能来自CSV文件、数据库或者API接口。

我们先来生成三组模拟数据,分别代表三种不同的分布情况,这在对比分析中很常见:

import numpy as np
import pandas as pd

# 设置随机种子,确保每次运行生成的“随机”数据都一样,便于复现结果
np.random.seed(42)

# 生成三组不同分布的数据,每组1000个样本
# 组1:标准正态分布,均值为0,标准差为1
data_normal = np.random.normal(loc=0, scale=1, size=1000)

# 组2:均值稍高、波动稍大的正态分布
data_shifted = np.random.normal(loc=1.5, scale=1.5, size=1000)

# 组3:均匀分布,范围在[-3, 3]
data_uniform = np.random.uniform(low=-3, high=3, size=1000)

# 将数据整理成Pandas DataFrame,这是Seaborn最喜爱的数据格式
# 使用‘melt’的思想,但这里我们直接构建一个“整洁数据”(Tidy Data)
df = pd.DataFrame({
    'Group_A': data_normal,
    'Group_B': data_shifted,
    'Group_C': data_uniform
})

# 查看数据前几行和基本统计信息
print("数据前5行:")
print(df.head())
print("\n数据基本描述(均值、标准差等):")
print(df.describe())

运行这段代码,你会看到一个包含三列的数据框,以及每组数据的统计摘要。Group_A集中在0附近,Group_B集中在1.5附近且更分散,Group_C则在-3到3之间均匀分布。理解数据的这些基本特征,有助于我们后续解读CDF图。

3. 方法一:用Seaborn的ecdfplot快速绘制

对于追求效率、希望快速探索数据分布差异的同学,Seaborn的ecdfplot函数是你的首选。它被称为“经验累积分布函数”绘图函数,其核心优势就是简单。你几乎不需要做任何数据预处理,直接把数据扔给它,它就能给你画出一个漂亮的CDF图。

3.1 基础绘制:一行代码出图

让我们用上面准备好的数据,来绘制第一张多组数据CDF对比图。

import seaborn as sns
import matplotlib.pyplot as plt

# 设置Seaborn的绘图样式,让图表更好看
sns.set_theme(style="whitegrid")

# 创建图形和坐标轴
plt.figure(figsize=(10, 6))

# 关键的一行代码:绘制多组数据的CDF
# `data=df` 指定数据来源是DataFrame `df`
# 默认情况下,Seaborn会将DataFrame的每一列视为一个独立的数据组,并绘制其CDF曲线
sns.ecdfplot(data=df)

# 添加图表装饰
plt.title('多组数据累积分布函数(CDF)对比 - Seaborn方法', fontsize=15, pad=20)
plt.xlabel('数据值', fontsize=12)
plt.ylabel('累积比例', fontsize=12)
plt.legend(title='数据组', title_fontsize=11) # 添加图例,标题为‘数据组’
plt.grid(True, linestyle='--', alpha=0.7) # 显示网格线,样式为虚线,半透明

# 显示图形
plt.tight_layout() # 自动调整子图参数,使之填充整个图像区域,避免标签重叠
plt.show()

执行完这段代码,你应该会看到一张清晰的图表。图上三条曲线从左到右,分别代表了Group_A(蓝)、Group_B(橙)、Group_C(绿)。你可以直观地看到:

  • 曲线位置Group_B的曲线整体最靠右,说明它的值普遍更大。
  • 曲线陡峭程度Group_A的曲线在中间部分最陡,说明它的数据最集中(方差小)。Group_C的曲线近似一条斜线,符合均匀分布的特征。
  • 对比特定值:在纵轴(累积比例)上画一条水平线,比如0.5(中位数),看它与三条曲线的交点,就能比较三组数据的中位数大小。

3.2 深度定制:让图表传达更多信息

默认的图表已经不错,但我们可以做得更好,让它更专业、信息量更大。Seaborn的ecdfplot提供了丰富的参数供我们调整。

plt.figure(figsize=(12, 7))

# 使用更高级的参数定制ecdfplot
ax = sns.ecdfplot(
    data=df,
    palette="Set2",           # 使用Set2配色方案,对色盲更友好
    linewidth=2.5,            # 加粗线条
    linestyle='-',            # 实线
    stat="proportion",        # 纵轴显示比例(默认就是它,也可选‘count’显示计数)
    complementary=False,      # 是否绘制互补CDF(即1-CDF),默认为False
    legend=True
)

# 添加重要的参考线
# 1. 添加中位数参考线(累积概率=0.5)
plt.axhline(y=0.5, color='grey', linestyle=':', linewidth=1.5, alpha=0.8, label='中位数 (50%)')
# 2. 添加90分位数参考线(累积概率=0.9)
plt.axhline(y=0.9, color='darkgrey', linestyle=':', linewidth=1.5, alpha=0.8, label='90分位数')

# 为了更精确,我们可以计算并标记出每组数据的中位数
medians = df.median()
colors = sns.color_palette("Set2", n_colors=3)
for idx, (col_name, median_val) in enumerate(medians.items()):
    # 在曲线上找到累积概率最接近0.5的点
    # 这里为了演示,我们简单地在x=中位数处画竖线
    plt.axvline(x=median_val, color=colors[idx], linestyle='--', linewidth=1, alpha=0.7)
    # 在图上添加文本标注
    plt.text(x=median_val+0.1, y=0.05+idx*0.05, s=f'{col_name}中位数: {median_val:.2f}',
             color=colors[idx], fontsize=9, verticalalignment='center')

plt.title('定制化CDF对比图:包含中位数与分位数参考线', fontsize=16, pad=20)
plt.xlabel('观测值', fontsize=13)
plt.ylabel('累积概率', fontsize=13)
# 合并图例(将Seaborn自动生成的图例和我们手动添加的参考线图例合并)
handles, labels = ax.get_legend_handles_labels()
# 手动添加参考线的句柄和标签
from matplotlib.lines import Line2D
custom_lines = [Line2D([0], [0], color='grey', linestyle=':', lw=1.5),
                Line2D([0], [0], color='darkgrey', linestyle=':', lw=1.5)]
ax.legend(handles + custom_lines, labels + ['中位数 (50%)', '90分位数'],
          title='图例', title_fontsize=11, loc='lower right')
plt.grid(True, which='both', linestyle='--', alpha=0.4)
plt.tight_layout()
plt.show()

这段代码生成的图表信息量就丰富多了。我们通过palette参数更换了更专业的配色,通过添加水平和垂直的参考线,清晰地标出了中位数和90分位数的位置。图例也进行了合并和整理。这样的图表放在报告里,即使是不太懂统计的同事或老板,也能快速抓住核心结论:比如,“B组的中位数最高,但A组有90%的数据都集中在更小的区间内,表现更稳定。”

4. 方法二:用SciPy进行精确计算与高级绘制

虽然Seaborn的ecdfplot非常方便,但有时候我们需要更底层的控制,或者想要基于理论分布(如正态分布)来绘制CDF进行对比。这时,SciPy库的stats模块就大显身手了。它允许我们精确计算任意点的CDF值,并进行更复杂的可视化。

4.1 手动计算并绘制经验CDF

首先,我们不借助Seaborn,而是用NumPy和Matplotlib手动计算并绘制经验CDF。这能帮助你更深刻地理解CDF是怎么来的。

import numpy as np
import matplotlib.pyplot as plt

plt.figure(figsize=(10, 6))

# 为每一组数据手动计算并绘制CDF
colors = ['blue', 'orange', 'green']
labels = ['Group_A (正态)', 'Group_B (偏移正态)', 'Group_C (均匀)']

for i, (col, color, label) in enumerate(zip(df.columns, colors, labels)):
    data = df[col].values
    # 1. 将数据从小到大排序
    sorted_data = np.sort(data)
    # 2. 计算累积概率
    # np.arange(1, len+1) 生成1,2,3,...,n
    # 除以 len(data) 得到比例。也可以除以 (len(data)+1) 或使用其他公式,这里用最常用的。
    cdf_values = np.arange(1, len(sorted_data)+1) / len(sorted_data)

    # 3. 绘制阶梯图或折线图
    # 使用‘steps-post’绘制阶梯图,能更真实地反映经验CDF的离散跳跃特性
    plt.step(sorted_data, cdf_values, where='post',
             color=color, linewidth=2, label=label, alpha=0.8)
    # 也可以使用plot画平滑曲线,视觉上更连续
    # plt.plot(sorted_data, cdf_values, color=color, linewidth=2, label=label)

plt.title('手动计算的经验CDF对比图', fontsize=15, pad=20)
plt.xlabel('数据值', fontsize=12)
plt.ylabel('累积概率 F(x)', fontsize=12)
plt.legend()
plt.grid(True, linestyle='--', alpha=0.5)
plt.ylim(0, 1.02) # 确保y轴从0开始,略高于1
plt.tight_layout()
plt.show()

这段代码揭示了CDF绘制的本质:排序和累积计数。step函数绘制的阶梯图是经验CDF的标准表示方法,它在每个数据点处发生跳跃。通过自己实现一遍,你对CDF的理解会远超单纯调用一个库函数。

4.2 结合理论分布:用SciPy拟合与对比

在实际分析中,我们经常想知道:“我的数据是否符合某种理论分布(比如正态分布)?” 将经验CDF与理论CDF进行对比,是一种非常有效的检验方法。SciPy的stats模块内置了大量理论分布。

from scipy import stats
import matplotlib.pyplot as plt
import numpy as np

plt.figure(figsize=(12, 8))

# 我们以Group_A(假设它来自正态分布)为例
data = df['Group_A'].values
sorted_data = np.sort(data)
empirical_cdf = np.arange(1, len(sorted_data)+1) / len(sorted_data)

# 使用SciPy拟合正态分布参数(均值和标准差)
fit_mean, fit_std = stats.norm.fit(data)
print(f"拟合的正态分布参数:均值 = {fit_mean:.4f}, 标准差 = {fit_std:.4f}")

# 生成理论正态分布的CDF值
# 在排序后的数据点位置上,计算理论CDF
theoretical_cdf = stats.norm.cdf(sorted_data, loc=fit_mean, scale=fit_std)

# 绘制双Y轴图,左侧为CDF,右侧为差异
fig, ax1 = plt.subplots(figsize=(12, 7))

# 左侧Y轴:绘制经验CDF和理论CDF
color_emp = 'tab:blue'
color_theo = 'tab:red'
ax1.set_xlabel('数据值', fontsize=13)
ax1.set_ylabel('累积概率 F(x)', fontsize=13, color='black')
line1 = ax1.step(sorted_data, empirical_cdf, where='post',
                 color=color_emp, linewidth=2.5, label='经验CDF (数据)')
line2 = ax1.plot(sorted_data, theoretical_cdf, color=color_theo,
                 linewidth=2.5, linestyle='--', label=f'理论CDF (正态拟合)')
ax1.tick_params(axis='y')
ax1.legend(loc='upper left')
ax1.grid(True, linestyle='--', alpha=0.3)
ax1.set_title('经验CDF vs. 理论正态CDF拟合对比', fontsize=16, pad=20)

# 右侧Y轴:绘制两者的绝对差异
ax2 = ax1.twinx()
diff = np.abs(empirical_cdf - theoretical_cdf)
color_diff = 'tab:green'
ax2.set_ylabel('|经验CDF - 理论CDF|', fontsize=13, color=color_diff)
line3 = ax2.fill_between(sorted_data, diff, color=color_diff, alpha=0.3, label='绝对差异')
ax2.tick_params(axis='y', labelcolor=color_diff)
# 将右侧Y轴的图例也合并进来
lines = line1 + [line2[0]] + [line3]
labels = [l.get_label() for l in lines]
ax1.legend(lines, labels, loc='center left')

fig.tight_layout()
plt.show()

# 可以进行Kolmogorov-Smirnov检验,量化拟合优度
ks_statistic, p_value = stats.kstest(data, 'norm', args=(fit_mean, fit_std))
print(f"\nK-S检验结果:")
print(f"  统计量 D = {ks_statistic:.4f}")
print(f"  P值 = {p_value:.4f}")
if p_value > 0.05:
    print("  结论:在0.05显著性水平下,无法拒绝数据来自该正态分布的假设。")
else:
    print("  结论:在0.05显著性水平下,数据显著偏离该正态分布。")

这个图表非常强大。左侧主图直观对比了蓝色的经验CDF阶梯线和红色的理论正态CDF曲线。如果两者吻合得很好,说明数据很可能服从正态分布。右侧的绿色填充区域展示了二者在每个点上的绝对差异,差异越大,填充面积越大。最后,我们还用K-S检验给出了一个统计量化的判断。这套组合拳下来,你对数据分布形态的把握就非常扎实了。

5. 实战案例:网站加载时间对比分析

光说不练假把式。让我们用一个更贴近实际的案例来巩固所学。假设你是一个数据分析师,公司网站进行了前端优化(比如压缩了图片、改进了代码)。优化前后,你收集了各1000次页面加载时间(单位:毫秒)的样本数据。你的任务是:用CDF对比图,清晰、有说服力地展示优化效果。

5.1 数据准备与探索

我们先模拟生成这两组数据。优化前的加载时间可能均值较高、长尾较严重(偶尔有很慢的加载)。优化后的加载时间均值降低,且更加稳定。

# 模拟网站页面加载时间数据(单位:毫秒)
np.random.seed(123) # 固定随机种子,保证结果可复现

# 优化前:均值1200ms,标准差400ms,并添加一些极端慢的加载(长尾)
load_before = np.random.normal(loc=1200, scale=400, size=950)
# 模拟50次极端慢的加载,服从指数分布
load_before_tail = np.random.exponential(scale=800, size=50) + 1200
load_before = np.concatenate([load_before, load_before_tail])
np.random.shuffle(load_before) # 打乱顺序
load_before = np.clip(load_before, 200, None) # 确保没有负值,最小值设为200ms

# 优化后:均值800ms,标准差200ms,长尾现象减轻
load_after = np.random.normal(loc=800, scale=200, size=980)
load_after_tail = np.random.exponential(scale=400, size=20) + 800
load_after = np.concatenate([load_after, load_after_tail])
np.random.shuffle(load_after)
load_after = np.clip(load_after, 200, None)

# 创建DataFrame
df_load = pd.DataFrame({
    '优化前': load_before,
    '优化后': load_after
})

print("加载时间数据描述:")
print(df_load.describe().round(2))
print(f"\n优化前,加载时间 > 2000ms 的极端情况占比:{(df_load['优化前'] > 2000).mean()*100:.1f}%")
print(f"优化后,加载时间 > 2000ms 的极端情况占比:{(df_load['优化后'] > 2000).mean()*100:.1f}%")

从描述性统计中,我们已经能看到优化后均值(Mean)和中位数(50%)明显下降,标准差(Std)也变小了,说明更稳定。极端慢加载的比例也大幅下降。

5.2 绘制业务导向的CDF对比图

现在,我们绘制一张能直接向业务方汇报的CDF对比图。这张图要突出关键业务指标,比如“90%的用户体验到的加载时间”、“中位数加载时间”,并清晰展示优化带来的提升。

plt.figure(figsize=(13, 8))

# 使用Seaborn绘制CDF,设置更商务的样式
sns.set_style("whitegrid")
ax = sns.ecdfplot(data=df_load, palette=["#E74C3C", "#2ECC71"], # 红色代表优化前,绿色代表优化后
                  linewidth=3, legend=True)

# 计算关键业务指标
metrics = {}
for col in df_load.columns:
    data = df_load[col].values
    metrics[col] = {
        'median': np.median(data),
        'p90': np.percentile(data, 90), # 90分位数
        'p95': np.percentile(data, 95),
        'p99': np.percentile(data, 99)
    }

# 在图上标注关键分位数
percentiles = [50, 90, 95, 99]
colors_percentile = ['grey', 'darkorange', 'purple', 'brown']
linestyles = [':', '-.', '--', '-']

for p, color, ls in zip(percentiles, colors_percentile, linestyles):
    plt.axhline(y=p/100.0, color=color, linestyle=ls, linewidth=1, alpha=0.7, label=f'{p}% 分位线')

# 为每条CDF曲线标注其中位数和P90值
for idx, col in enumerate(df_load.columns):
    median_val = metrics[col]['median']
    p90_val = metrics[col]['p90']
    # 在曲线上找到对应的点进行标注
    # 简单起见,我们在对应x值处画竖线,并添加文本
    plt.axvline(x=median_val, color=sns.color_palette()[idx], linestyle=':', alpha=0.8, linewidth=1.5)
    plt.axvline(x=p90_val, color=sns.color_palette()[idx], linestyle='--', alpha=0.8, linewidth=1.5)
    # 添加文本标注,使用箭头指向
    ax.annotate(f'中位数: {median_val:.0f}ms',
                xy=(median_val, 0.5), xycoords='data',
                xytext=(20, 20+idx*30), textcoords='offset points',
                arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=-0.2", color=sns.color_palette()[idx]),
                fontsize=10, color=sns.color_palette()[idx])
    ax.annotate(f'P90: {p90_val:.0f}ms',
                xy=(p90_val, 0.9), xycoords='data',
                xytext=(20, 10-idx*30), textcoords='offset points',
                arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=0.2", color=sns.color_palette()[idx]),
                fontsize=10, color=sns.color_palette()[idx])

# 添加性能提升的阴影区域(例如,优化后P90以内的区域)
# 找到优化后曲线P90对应的x值
p90_after = metrics['优化后']['p90']
# 填充优化后曲线在P90左侧的区域,表示“良好体验区间”
# 这里需要获取优化后曲线的数据点,我们近似处理:在x轴[最小值, p90_after]区间填充
x_fill = np.linspace(df_load['优化后'].min(), p90_after, 100)
# 由于我们不知道Seaborn内部计算的精确CDF值,这里用插值近似
from scipy.interpolate import interp1d
sorted_after = np.sort(df_load['优化后'].values)
cdf_after = np.arange(1, len(sorted_after)+1) / len(sorted_after)
f = interp1d(sorted_after, cdf_after, kind='linear', bounds_error=False, fill_value=(0, 1))
y_fill = f(x_fill)
plt.fill_between(x_fill, 0, y_fill, color='#2ECC71', alpha=0.15, label='优化后P90内良好体验区')

plt.title('网站前端优化前后页面加载时间CDF对比分析', fontsize=18, pad=25, fontweight='bold')
plt.xlabel('页面加载时间 (毫秒)', fontsize=14)
plt.ylabel('累积概率 (用户比例)', fontsize=14)
plt.legend(loc='lower right', fontsize=11)
plt.grid(True, which='both', linestyle='--', alpha=0.4)
# 设置x轴范围,聚焦主要区域
plt.xlim([0, 2500])
plt.tight_layout()

# 在图表下方添加一个总结性文本框
summary_text = (f"优化效果总结:\n"
                f"• 中位数加载时间从 {metrics['优化前']['median']:.0f}ms 降至 {metrics['优化后']['median']:.0f}ms,提升 {((metrics['优化前']['median']-metrics['优化后']['median'])/metrics['优化前']['median']*100):.1f}%。\n"
                f"• P90加载时间从 {metrics['优化前']['p90']:.0f}ms 降至 {metrics['优化后']['p90']:.0f}ms,提升 {((metrics['优化前']['p90']-metrics['优化后']['p90'])/metrics['优化前']['p90']*100):.1f}%。\n"
                f"• 极端慢加载(P99)从 {metrics['优化前']['p99']:.0f}ms 降至 {metrics['优化后']['p99']:.0f}ms。")
plt.figtext(0.5, -0.05, summary_text, ha="center", fontsize=11, bbox={"facecolor":"lightgray", "alpha":0.3, "pad":5})

plt.show()

这张图几乎可以直接放进你的汇报PPT里。它清晰地展示了:

  1. 整体提升:绿色曲线(优化后)整体大幅向左移动,意味着在任何用户比例下,加载时间都更短。
  2. 关键指标:明确标注了中位数和P90值,这是业务最关心的指标。从标注可以看出,中位数和P90都有显著下降。
  3. 稳定性改善:绿色曲线更陡峭,尤其是在中间部分,说明数据更集中,用户体验更一致。
  4. 尾部优化:在图表右侧(长尾部分),绿色曲线也更靠左,说明极端慢加载的情况得到了极大改善。
  5. 直观区间:绿色阴影区域直观地展示了“优化后90%用户享有的良好体验时间区间”。

通过这样一张信息丰富的CDF对比图,你可以非常有说服力地向团队证明:这次前端优化是卓有成效的,不仅提升了平均速度,更重要的是改善了尾部体验,让更多用户获得了快速稳定的访问感受。这就是数据可视化的力量,也是Seaborn和SciPy结合使用的魅力所在。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐