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
。
完。
【翻译能力有限,错误在所难免,欢迎指出】
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。