7.2 大型数据集的绘图策略 大数据集画不动时按三级减负:一级抽样(保留散点形态)、二级聚合(直方图与二维密度天然可扩展)、三级换图层(hexbin、二维 kde 代替散点)。同时砍掉 bootstrap 置信区间的计算成本。 第 3.1 节讲过万级点的过绘,第 3.2 节提过 bootstrap 慢,本节把性能话题收拢成一个按数据量分级的决策体系。 先量级,再选策略 数据量 | 策略 | 典型手段 万级以内 | 直接画 | alpha 加小点径 十万级 | 一级抽样 | df.
大数据集画不动时按三级减负:一级抽样(保留散点形态)、二级聚合(直方图与二维密度天然可扩展)、三级换图层(hexbin、二维 kde 代替散点)。同时砍掉 bootstrap 置信区间的计算成本。
第 3.1 节讲过万级点的过绘,第 3.2 节提过 bootstrap 慢,本节把性能话题收拢成一个按数据量分级的决策体系。
| 数据量 | 策略 | 典型手段 |
|---|---|---|
| 万级以内 | 直接画 | alpha 加小点径 |
| 十万级 | 一级抽样 | df.sample 保形态 |
| 百万级 | 二级聚合 | histplot 二维、kde 网格 |
| 千万级以上 | 预聚合 | groupby 后画摘要 |
import numpy as np import pandas as pd import seaborn as sns import matplotlib.pyplot as plt import time rng = np.random.default_rng(3) big = pd.DataFrame({ 'x': rng.normal(0, 1, 1_000_000), 'y': rng.normal(0, 1, 1_000_000) * 0.6 + rng.normal(0, 1, 1_000_000), }) # 基准:百万散点 t0 = time.perf_counter() ax = sns.scatterplot(data=big, x='x', y='y', s=4, alpha=0.05) plt.close() print(f'散点 100 万点:{time.perf_counter() - t0:.2f} 秒') # 输出示例:散点 100 万点:3.8 秒 —— 能画,但改一次参数等一次
# 分层抽样:按 x 分位数分层,每层等量抽,防尾部被随机抽稀 big['stratum'] = pd.qcut(big['x'], 10, labels=False) sampled = big.groupby('stratum', group_keys=False).apply( lambda d: d.sample(20_000, random_state=1)) print(len(sampled)) # 输出:200000 t0 = time.perf_counter() ax = sns.scatterplot(data=sampled, x='x', y='y', s=4, alpha=0.15) plt.close() print(f'分层抽样 20 万点:{time.perf_counter() - t0:.2f} 秒') # 输出示例:分层抽样 20 万点:0.5 秒 —— 快一个数量级, # 相关系数从 0.83 到 0.83,尾部覆盖完整
简单随机抽样在大偏态数据上会亏尾部:百万分之几的极端值可能一个都抽不到。分层抽样多花两行代码,买回尾部保真。
t0 = time.perf_counter() ax = sns.histplot(data=big, x='x', y='y', bins=120, cbar=True) plt.close() print(f'二维直方图 100 万点:{time.perf_counter() - t0:.2f} 秒') # 输出示例:二维直方图 100 万点:0.3 秒 —— 比散点还快, # 因为计算只是分箱计数,与点数线性相关且无逐点渲染
# 二维 kde:网格化后计算,点数的影响被二次采样摊薄 ax = sns.kdeplot(data=big.sample(100_000), x='x', y='y', levels=8, fill=True) # 核密度在大样本上仍然贵,先抽十万再估是性价比最高的组合
flights_long = sns.load_dataset('flights').pipe( lambda d: d.loc[d.index.repeat(2000)].reset_index(drop=True)) # 模拟大样本 t0 = time.perf_counter() ax = sns.lineplot(data=flights_long, x='year', y='passengers') # 默认 bootstrap 1000 次 plt.close() print(f'默认 ci:{time.perf_counter() - t0:.2f} 秒') # 输出示例:默认 ci:6.4 秒 t0 = time.perf_counter() ax = sns.lineplot(data=flights_long, x='year', y='passengers', errorbar='sd') plt.close() print(f'errorbar sd:{time.perf_counter() - t0:.2f} 秒') # 输出示例:errorbar sd:0.1 秒 —— 巨大样本下置信区间窄到看不清, # bootstrap 的千次重采样纯属浪费;sd 或 pi 口径又快又诚实地展示离散
背景:一份用户行为日志(模拟一千万行),要快速回答"响应时长与并发数的关系",全量画图不可行。
操作:
# 模拟数据(真实场景从数据库读) n = 10_000_000 log = pd.DataFrame({ 'conc': rng.integers(1, 200, n), # 并发数 'latency': np.abs(rng.normal(120, 40, n)) + np.repeat( np.linspace(0, 80, 200), n // 200), # 与并发正相关的延迟 }) # 第一步:预聚合——按并发分箱取分位数摘要 log['conc_bin'] = pd.cut(log['conc'], 20) summary = log.groupby('conc_bin')['latency'].quantile([0.25, 0.5, 0.75]).unstack() summary.columns = ['q25', 'median', 'q75'] summary = summary.reset_index() summary['mid'] = summary['conc_bin'].astype(str).str.extract(r'(\d+\.?\d*)').astype(float) print(summary.head(3)) # 输出:每 10 并发一档的三个分位点,一千万行压成 20 行 # 第二步:画摘要带 ax = plt.gca() ax.fill_between(summary['mid'], summary['q25'], summary['q75'], alpha=0.25, label='四分位距') ax.plot(summary['mid'], summary['median'], color='#b03a2e', label='中位数') ax.set_xlabel('并发数') ax.set_ylabel('响应时长(毫秒)') ax.legend()
结果解读:中位数曲线随并发线性爬升,四分位距几乎等宽——延迟增长是系统性的(容量问题)而非尾部恶化(个别慢查询)。这个结论在两万行的散点图上同样能猜到,但预聚合版三秒出图且可直接进报告。变式:追加 0.95 分位列画出"慢请求尾巴",区分容量问题与长尾问题。
⚠️ 常见坑:
log.groupby(...).quantile()在分箱列是 Interval 类型时索引难用,务必 reset_index 后把区间转成数值中点(如上例的 extract 流程),否则后续 x 轴映射会报类型错。
💡 关键直觉:大数据集的图几乎总是"摘要图"——与其说在画数据,不如说在画一个聚合统计量的置信度。想清楚要摘要什么,比想怎么画快更重要。
本节要点回顾
- 先量级后策略:万直画、十万抽样、百万聚合、千万预聚合;
- 分层抽样保尾部:qcut 分层比简单随机多两行代码,少一个坑;
- 二维直方图是被低估的快枪手:分箱计数与渲染成本都与形态无关;
- 大样本砍 bootstrap:误差带窄到不可见时换 sd 或 pi 口径;
- 预聚合出报告图:分位数摘要带是千万行数据的标准交付形态。