Common Plotting Pitfalls that get Worse with large data
说在前面
在看 ”Mastering Python for Finance” 这本书的时候,作者推荐了一个收集 有趣的Jupyter notebook 的集合,其中涵盖的门类非常广。
其中由 James A. Bednar 撰写的一篇文章,介绍在可视化大量数据的时候容易犯的错误。利用 Jupyter notebook 直观而且容易分享和展示的特点,Bednar 直观地讨论了这些很容易掉入,但不容易意识到的陷阱。
原文链接
在利用可视化 (Visualization) 方法来从数据(通常极大量,以至于我们不可能一个个地分析每个数据点)中获取信息的过程中,我们可能会沉浸在绘制的细节中,从而过快地下结论,而不对这些结论产生应有的怀疑。但是事实上,我们往往因为某些绘图参数的偶然性,进入到一些陷阱中,从而产生了错误的观察和错误的结论。了解这些常见的陷阱,合理地怀疑和推敲自己的结论,才能挖掘数据中的金子,而不是垃圾。
原文开始
我们会在这篇文章中讨论:
- Overplotting(过密绘图)
- Oversaturation(过饱和)
- Undersampling(采样不足)
- Undersaturation(欠饱和)
- Underutilized range(未充分利用灰度范围)
- Nonuniform colormapping(非均匀颜色映射)
,以及如何减轻或者消除他们。
依赖安装
我们将会需要这些包,可以运行下面的命令安装:
conda install -c bokeh -c ioam holoviews colorcet matplotlib scikit-image
1. Overplotting
我们首先将来自两个不同来源的数据集绘制在同一坐标系下 –A 图中的点和 B 图中的点。出人意料的是,先画 A 还是先画 B,我们看到的结果非常不同。
def blues_reds(offset=0.5,pts=300):
blues = (np.random.normal( offset,size=pts), np.random.normal(offset,size=pts), -1*np.ones((pts)))
reds = (np.random.normal(-offset,size=pts), np.random.normal(-offset,size=pts), 1*np.ones((pts)))
return hv.Points(blues, vdims=['c']), hv.Points(reds, vdims=['c'])
blues,reds = blues_reds()
blues + reds + reds*blues + blues*reds
C 图和 D 图显示的是同一组数据,但是给人截然不同的印象:在 C 图中蓝色的点显得更多,而在 D 图中红色的点显得更多。单凭 C 图或者 D 图,我们就会得到错误的结论。实际上两种点同样多,这里在作祟的是 occlusion(闭塞)。
一个数据集被另一个数据集闭塞时,叫做overplotting(过密绘图,没有找到翻译,译者自己创造的)或者overdrawing,任何时候我们将一个点或者一条线叠加在另一个点或者另一条线上,都可能发生。不仅是散点图,曲线图、3D 曲面图等等都会出现 occlusion。
2. Oversaturation
可以通过减小 alpha 值来减轻过密绘图。alpha 是绘图软件上提供的控制透明度的参数,例如:如果 alpha 是 0.1,当十个数据点重合的时候,涂色达到饱和。这样以来,绘图顺序的影响会变小,但是看清每个点变得更加困难。
%%opts Points (s=50 alpha=0.1)
blues + reds + reds*blues + blues*reds
这里,C 和 D 看起来非常相似(也理应如此,因为分布是一样的),但是在一些地方(有超过十个点重合的地方),还是有过饱和(oversaturation)问题。在这幅图里,过饱和出现在中间。检测过饱和的唯一可靠方法是透过绘制两个版本,然后比较,或者依靠检查像素值来看是否有达到饱和的像素(必要但是不充分的条件)。设置了 alpha 之后,当很多个点重合在一起,只有最后被绘制上去的十个点会影响最终的颜色(alpha=0.1)。
坏消息是,正确的 alpha 值和每个数据集有关。如果一个图中较多点重合在某个特定的区域,手动调参得到的 alpha 值可能还是会失实描述这个数据集。
%%opts Points (alpha=0.1)
blues,reds = blues_reds(pts=600)
blues + reds + reds*blues + blues*reds
例如这里,C 和 D 还是看起来不同,但是他们本该是很相似地。既然可视化数据的意义是研究数据集,那么必须根据数据集的特性,手动找出某个参数是一个非常不好的迹象。
%%opts Points (s=10 alpha=0.1 edgecolor=None)
blues + reds + reds*blues + blues*reds
即便是对于很小的数据集,找到正确的数据点面积和 alpha 参数也不容易。对于越来越大的未知性质数据集,我们更加容易犯这些错误而没意识到。
3. 采样过疏(undersampling)
我们现在改变一下数据,换成一个单一种类的数据集。单一种类的数据集中,过饱和会模糊密度上的区别。举个例子,当 alpha 等于 0.1,10 个、20 个、2000 个同一种类(同一颜色)的点重合在一起,他们看起来是完全一样的。
我们生成两个中心离开一点距离的正态分布,然后把他们叠加在一起,且不区分两个种类(所有的点都用黑色标记)。
%%opts Points.Small_dots (s=1 alpha=1) Points.Tiny_dots (s=0.1 alpha=0.1)
def gaussians(specs=[(1.5,0,1.0),(-1.5,0,1.0)],num=100):
"""
A concatenated list of points taken from 2D Gaussian distributions.
Each distribution is specified as a tuple (x,y,s), where x,y is the mean
and s is the standard deviation. Defaults to two horizontally
offset unit-mean Gaussians.
"""
np.random.seed(1)
dists = [(np.random.normal(x,s,num), np.random.normal(y,s,num)) for x,y,s in specs]
return np.hstack([d[0] for d in dists]), np.hstack([d[1] for d in dists])
hv.Points(gaussians(num=600), label="600 points", group="Small dots") + \
hv.Points(gaussians(num=60000), label="60000 points", group="Small dots") + \
hv.Points(gaussians(num=600), label="600 points", group="Tiny dots") + \
hv.Points(gaussians(num=60000), label="60000 points", group="Tiny dots")
参数依然导致结果很不稳定。对于 600 个数据点,小点(0.1 面积,alpha=1)效果很好,但是大的数据集,overplotting 问题会模糊分布 B 的形状和密度;微小点(比小点小十倍,alpha=0.1)在大数据集 D 中表现很好,但是在 C 中非常的差,几乎一个点都看不到。
凭借现有的绘图软件地计算力,随着数据集越来越大,绘制全部数据的散点图会在某一个时刻变得不可能。为了解决,人们有时候简单地抽样出一万个点之后绘制。但是在 A 图中能看到,如果一个分布很不幸地被 undersample 了,我们有可能看不出形状。要让分布的形态可见,必须要有足够的数据点在稀疏的区域,和正确的绘图参数,但这需要反复试错。
研究者有时使用热力图而不是散点图。一个热力图有若干个固定大小的网格。热力图不限制数据点的总理。热力图实际上逼近给定空间内的概率密度函数。
更粗糙的热力图将噪音抹掉来展示真实的分布,而更精细的热力图可以展示更多的细节。
我们来看看一组表达同一个分布地热力图,唯一不同的网格的数量(number of bins)。
def heatmap(coords,bins=10,offset=0.0,transform=lambda d,m:d, label=None):
hist,xs,ys = np.histogram2d(coords[0], coords[1], bins=bins)
counts = hist[:,::-1].T
transformed = transform(counts,counts!=0)
span = transformed.max()-transformed.min()
compressed = np.where(counts!=0,offset+(1.0-offset)*transformed/span,0)
args = dict(label=label) if label else {}
return hv.Image(compressed,bounds=(xs[-1],ys[-1],xs[1],ys[1]),**args)
hv.Layout([heatmap(gaussians(num=60000),bins) for bins in [8,20,200]])
、
A 过于粗糙,不能准确地描述分布。有足够多网格地 C,逼近散点图(上图的 D)。有中等数量网格的 B 可以磨平采样过疏;B 实际上比 C 更忠实于数据,但是 C 更好的代表了抽样(译者也没明白这句话)。所以寻找一个合适的热力图网格大小需要一些经验,对比多个不同网格大小的热力图可以提供一些帮助。至少,网格大小这个参数是一个有意义的东西,在数据而不属于绘图的细节 – 比如点有多透明,这些东西往往是被随机确定下来的。
原则上来讲,热力图方法可以完全避免以上三个陷阱:
- overplotting(过密绘图):取一个网格内的点的算术和,一个点不会模糊另一个点
- oversaturation(过饱和):最大和最小的计数会被自动地分配到可视灰度区间的两端
- undersampling(采样过疏):画出来的图的大小不依赖于总的数据点的多少,我们可以有无限多的数据输入
4. Undersaturation
当然,热力图有自己的缺陷。热力图和附带 alpha 的散点图都会有,又很少被意识到的一个问题是 undersaturation。在这个问题出现时,大量的数据点可能会被忽略,因为它们要么分布在非常广大的网格中,或者在很多几乎透明的散点中。我们来看看一组有着不同的分散度(标准差)的高斯分布:
dist = gaussians(specs=[(2,2,0.02), (2,-2,0.1), (-2,-2,0.5), (-2,2,1.0), (0,0,3)],num=10000)
hv.Points(dist) + hv.Points(dist)(style=dict(s=0.1)) + hv.Points(dist)(style=dict(s=0.01,alpha=0.05))
A,B,C 图画的是同一组数据的:5 个不同中心,不同标准差的高斯分布:
- 中心(2,2):极窄分布
- 中心(2,-2):窄分布
- 中心(-2,-2):中等分布
- 中心(-2,2):散分布
- 中心(0,0):极散分布
在 A 图中,最散的分布(分布 5)覆盖了一切,我们完全看不到任何的结构。B 和 C 好一点,但还是不能令人满意。在 B 中有四个明显的高斯分布,除了最大的之外都看起来有着相同的密度,最窄的分布的乎看不到。然后,我们尝试改变 alpha,得到 C。undersaturation 出现了:最离散的高斯分布完全消失了!如果我们只看 C,就会弄丢这个分布。
那么热力图可以幸免欠饱和吗?
hv.Layout([heatmap(dist,bins) for bins in [8,20,200]])
非常窄的分布,表现为一些有着非常高计数的网格。因为色谱和数轴的映射是线性的,其他的网格比起来实在是太淡了。C 甚至成了一篇雪白。
为了避免欠饱和,你可以增加一个补偿来确保低计数(但非零)的网格被映射为一个可视颜色,剩下的色谱区间用来表达计数的差异。
hv.Layout([heatmap(dist,bins,offset=0.2) for bins in [8,20,200]]).cols(4)
欠饱和被完美地消除了:像素要么是零(纯白色),要么是一个我们选定的非背景颜色。最大的高斯分布可以被清楚地看见。
可是,五个分布不同的高斯的结构还是不能被辨别出来。A 太粗糙了,B 也过于粗糙。C 的粗糙度合适,但是它看来像是只有一个最大的高斯分布,而不是五个。
5.(未利用色谱区间)Underutilized range
为什么 C 呈现这个样子呢?这个陷阱更微妙:五个高斯分布之间,数据点密度的区别难以被眼睛捕捉,因为几乎所有的像素,要么在可视区间的底部(浅灰色),要么是在顶部(纯黑色)。其他的可视区间直接被丢弃了,完全没被利用起来!丰富内在的结构没有被传递出来。如果如果数据点均匀地分布在 0 到 10000 区间中,那么上面的图没有问题,但现实中很少是这样。
所以,我们应该将计数转换成某些更好的,能视觉上表达计数的相对区别的指数。这个指数应该能保存计数的相对区别,但是又能将他们映射在整个可视区间内。对数转换是一个选择:
hv.Layout([heatmap(dist,bins,offset=0.2,transform=lambda d,m: np.where(m,np.log1p(d),0)) for bins in [8,20,200]])
很棒!C 里面,我们可以清楚看到细节。五个高斯分布不同的离散度在 C 中很清楚。
还有一个疑问:为什么是对数转换?对数转换奏效,其实跟我们的标准差大致遵循一个等比数列有关。那么对于更大的,未知结构的数据,有一些指引我们转换的原则吗?
有。我们换个思路,其实在绘制这个数据集的时候,我们遇到的困难源自每个网格里的计数 差距过大 ,从 10000(非常窄)到 1(非常离散)。普通的显示器只有 256 个灰度,而且人类能感知不同灰度的能力是有限的,如果把数据直接映射到灰度上,效果不会很好。既然我们已经在上面的方法中抛弃了直接映射,而用了对数转换来克服欠饱和,那我们能不能完全摈弃数值映射这个思路,而使用相对排序呢?这样的图会保留顺序(order) 而不是量度(magnitude)。假设显示器有 100 个灰度,最低的 1% 的网格会被分配第一个可视的灰度,下一个 1% 会被分配到下一个可视的灰度,……,最高的 1% 会被分配到灰度 255(黑色)。真实的数值会被忽略掉,但是他们的相对量仍然会决定他们在屏幕上显示什么颜色。所以,分布的结构而不是数值被保存下来。
使用于图像处理包中的 histogram equalization function,每个灰度大约会有同样数量的像素:
try:
from skimage.exposure import equalize_hist
eq_hist = lambda d,m: equalize_hist(1000*d,nbins=100000,mask=m)
except ImportError:
eq_hist = lambda d,m: d
print("scikit-image not installed; skipping histogram equalization")
hv.Layout([heatmap(dist,bins,transform=eq_hist) for bins in [8,20,200]])
C 图现在很完美:五个高斯分布清晰可见,而且我们没有使用任何随意确定的参数。当然,我们也丢失了原始的计数。
6. 不均匀颜色映射
每个热力图需要一个颜色映射,也就是,一个从数值查询像素颜色的表。可视化的目的是解释数据的特性,为了这一点我们需要挑选可以让观察者客观地感知数据的颜色映射。不幸的是,绘图程序大多数常用的颜色映射都是不均匀的(也是不好的)。
比如,在’jet’(2015 年前 matlab 和 maplotlib 使用的默认颜色映射)中,很大一部分的数值会落在绿色中很难区分的一段,在‘hot’ 中,落在黄色中很难区分的一段。
来看看我们之前用的数据,在两个不同的颜色映射下,有什么区别:
import colorcet
hv.Layout([heatmap(dist,200,transform=eq_hist,label=cmap)(style=dict(cmap=cmap)) for cmap in ["hot","cet_fire"]]).cols(2)
很明显,cet_fire
的表达能力更强,也更精准地表现出区块之间的密度差异。hot
将所有的高密度区域都映射到亮黄色 / 白色中难以在官能上区分的一段,给我们一种“过饱和”的感觉(但我们的绘制原理其实应该确保了过饱和不会出现)。幸运的是,各个语言中都有不少均匀颜色映射:colorcet
包中的 50 多个,或者 matplotlib
自带的四个(viridis
,plasma
,inferno
,magma
),或者 Matlab 的Parula
。
完。
【翻译能力有限,错误在所难免,欢迎指出】