Common Plotting Pitfalls that get Worse with large data

说在前面

在看"Mastering Python for Finance"这本书的时候,作者推荐了一个收集有趣的Jupyter notebook的集合,其中涵盖的门类非常广。

其中由James A. Bednar撰写的一篇文章,介绍在可视化大量数据的时候容易犯的错误。利用Jupyter notebook直观而且容易分享和展示的特点,Bednar直观地讨论了这些很容易掉入,但不容易意识到的陷阱。

原文链接

在利用可视化(Visualization)方法来从数据(通常极大量,以至于我们不可能一个个地分析每个数据点)中获取信息的过程中,我们可能会沉浸在绘制的细节中,从而过快地下结论,而不对这些结论产生应有的怀疑。但是事实上,我们往往因为某些绘图参数的偶然性,进入到一些陷阱中,从而产生了错误的观察和错误的结论。了解这些常见的陷阱,合理地怀疑和推敲自己的结论,才能挖掘数据中的金子,而不是垃圾。

原文开始

我们会在这篇文章中讨论:

  1. Overplotting(过密绘图)
  2. Oversaturation(过饱和)
  3. Undersampling(采样不足)
  4. Undersaturation(欠饱和)
  5. Underutilized range(未充分利用灰度范围)
  6. 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

clipboard.png

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

clipboard.png

这里,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

clipboard.png

例如这里,C和D还是看起来不同,但是他们本该是很相似地。既然可视化数据的意义是研究数据集,那么必须根据数据集的特性,手动找出某个参数是一个非常不好的迹象。

%%opts Points (s=10 alpha=0.1 edgecolor=None)
blues + reds + reds*blues + blues*reds

clipboard.png

即便是对于很小的数据集,找到正确的数据点面积和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")

clipboard.png

参数依然导致结果很不稳定。对于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]])

clipboard.png

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))

clipboard.png

A,B,C图画的是同一组数据的:5个不同中心,不同标准差的高斯分布:

  1. 中心(2,2):极窄分布
  2. 中心(2,-2):窄分布
  3. 中心(-2,-2):中等分布
  4. 中心(-2,2):散分布
  5. 中心(0,0):极散分布

在A图中,最散的分布(分布5)覆盖了一切,我们完全看不到任何的结构。B和C好一点,但还是不能令人满意。在B中有四个明显的高斯分布,除了最大的之外都看起来有着相同的密度,最窄的分布的乎看不到。然后,我们尝试改变alpha,得到C。undersaturation出现了:最离散的高斯分布完全消失了!如果我们只看C,就会弄丢这个分布。

那么热力图可以幸免欠饱和吗?

hv.Layout([heatmap(dist,bins) for bins in [8,20,200]])

clipboard.png

非常窄的分布,表现为一些有着非常高计数的网格。因为色谱和数轴的映射是线性的,其他的网格比起来实在是太淡了。C甚至成了一篇雪白。

为了避免欠饱和,你可以增加一个补偿来确保低计数(但非零)的网格被映射为一个可视颜色,剩下的色谱区间用来表达计数的差异。

hv.Layout([heatmap(dist,bins,offset=0.2) for bins in [8,20,200]]).cols(4)

clipboard.png

欠饱和被完美地消除了:像素要么是零(纯白色),要么是一个我们选定的非背景颜色。最大的高斯分布可以被清楚地看见。

可是,五个分布不同的高斯的结构还是不能被辨别出来。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]])

clipboard.png

很棒!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]])

clipboard.png

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)

clipboard.png

很明显,cet_fire的表达能力更强,也更精准地表现出区块之间的密度差异。hot将所有的高密度区域都映射到亮黄色/白色中难以在官能上区分的一段,给我们一种“过饱和”的感觉(但我们的绘制原理其实应该确保了过饱和不会出现)。幸运的是,各个语言中都有不少均匀颜色映射:colorcet包中的50多个,或者matplotlib自带的四个(viridis,plasma,inferno,magma),或者Matlab的Parula

完。

【翻译能力有限,错误在所难免,欢迎指出】


yangrq1018
6 声望2 粉丝

引用和评论

0 条评论