可视化技能之Matplotlib(下)|可视化系列02

Matplotlib tries to make easy things easy and hard things possible.

在本系列的上篇文章里,我们从Matplotlib的基础可视化框架开始,逐步画出折线图、柱状图等基础图表,通过对坐标轴标签、标题文本等的精细调节画出信息更明确丰富的可视图,也实践了双轴图及子图,最后看了下极坐标系下绘图的效果。本篇继续探索Matplotlib的强悍可视化能力。

Matplotlib动态可视化

计算机及通信技术的发展极大丰富了多媒体内容的发展,文不如图、图不如动图;BI近些年也逐渐发展,人们已不满足于看静态的图表。短视频的火热也给了动态图更多的发展空间。动态图和交互图表能更生动地表现数据变化及数据联系,传达更多的信息。

插入排序的动态展现

生动的动画有助于我们理解算法。通过Matplotlib其实我们也可以绘制动态的算法关键过程,下面拿插入排序作为例子看Matplotlib如何绘制动态图。

玩扑克时的抓牌环节很契合插入排序的执行过程。其思路是:保持手中的已有的牌始终有序,当抓到一张新牌时,按照牌面的点数,将其插入合适的位置。怎么去判断该插入的位置呢?我们通常的做法就是从左到右或从右到左扫描以找到当前牌的位置,初始化时我们可以新建一个数组作为始终有序的结果集,也可以直接用原来的数组空间进行交换操作,整体时间复杂度是O(n^2)。将这一过程翻译为Python代码如下:

1
2
3
4
5
6
7
8
9
10
def isort(lst):
n=len(lst) #直接用原数组进行排序
for i in range(1,n):
x=lst[i] #当前值
j=i-1
while j>=0 and x<lst[j]: #从右往左找插入的位置
lst[j+1]=lst[j] #将比x大的牌往后移一位
j-=1
lst[j+1]=x #换牌
return lst

每次抓牌时判断新牌的合适位置

为了直观展示插入排序的关键步骤,我们将每做一次插入的结果保存下来然后用Matplotlib画成一系列柱状图。通过matplotlib.animation绘制成动态图。

首先改一下排序函数,增加一个变量保存每次到插入步骤时的数组,因为不是递归的排序代码,在for循环前用一个变量w保存关键结果,基于这些中间结果花一系列的图,再连成动态GIF图,代码如下,关键步骤都有注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def isort(lst): #插入排序代码
n=len(lst)
w={0:{'v':lst.copy(),'j':-1}} #保存j以确定到哪里插入
for i in range(1,n):
x=lst[i] #当前值
j=i-1
while j>=0 and x<lst[j]: #x比j处值小时,继续向左
lst[j+1]=lst[j] #将比x大的牌往后移一位
j-=1
lst[j+1]=x #换牌
w[i]={'v':lst.copy(),'j':j}
#print(xs,i,j,x)
return w #不需要lst

import matplotlib.animation as anm #引入接口
fig,ax=plt.subplots()
def draw_bar(i): #每传入一个i画一个柱状图
w=wk[i]
nw=len(w['v'])
ax.clear() #清空之前画的元素
c1=[]
for j in range(nw): #调节柱的颜色
if j<i:
c1.append('#1EAFAE')
elif i==nw-1:
c1.append('#1EAFAE')
elif j==i:
c1.append('#BA5C25')
else:
c1.append('#69FFFF')
if i!=nw-1 and w['j']+1!=i: #所交换的位置
c1[w['j']+1]='#FFA069'
rs=ax.bar(range(nw),w['v'],color=c1)
ax.set_ylim(0,8)
ax.set_title('Insert Sorted Animation')
c=0
for r in rs: #给每个柱加文本标签
ax.annotate('{0}'.format(w['v'][c]),xy=(r.get_x()+r.get_width()/2,r.get_height()+0.1))
c+=1
#绘制动图
amt=anm.FuncAnimation(fig,draw_bar,frames=range(6),interval=600)
amt.save('insert-sorted-animation-1.gif')

拿一个未排序数组进行测试,效果如下:

【排序过程GIF图 2】

排序过程动图。(青色表示已排序元素,淡蓝色表示未排序,枣红色柱表示当前需排序元素,插入到橙色柱位置)

Matplotlib绘制动态图表的思路是将一系列图按一定时间间隔顺序播放,利用眼睛的视觉暂留形成动态感,每张静态图就是一帧。上面的代码看上去有些长,但大部分语句用来调颜色和标签文本,画图部分仍然是熟悉的fig,ax=plt.subplots()建画布、ax.bar()画柱状图、ax.set_title()设置标题、ax.annotate()在每个柱的合适位置加文本标签。Matplotlib将动图相关的接口封装在matplotlib.animation里,FuncAnimation(fig,func,frames)通过重复调用func里的画图函数在fig上形成动图。FuncAnimation的参数如下:

  • fig:用来生成动画的画布;
  • func:通过调用matplotlib绘图方法来出图作为动图的每一帧;
  • frames:一个迭代对象,会将其中每一个元素作为绘制一帧的参数传入func函数;
  • interval:每一帧的展示时间,默认200,单位是毫秒,也就是200毫秒跳到下一张图;

要将动图保存到文件通过.save(fname)实现,另外也可以用.to_html5_video()把动画转为HTML5下video标签支持的数据或用.to_jshtml生成HTML表示的动画数据,例如在jupyter notebook环境中,可以用以下语句直接渲染出带播放控制台的动图。

【带控制台的动图播放,3】

动态排序图实践

学动态图绘制不应该错过一直挺热门的动态排序图(Bar Chart Race)。通过一系列的条形图营造出你追我赶的热闹场面,看尽事件的变迁。特别适合的应用场景是各种排名的变化,如城市排名变化、某些主题搜索指数变化、××沉浮史等。把这类图拆解一下看到的是一系列条形图和条柱之间的交换动态效果。有了上面的插入排序做热身,同样可以通过绘制一系列条形图再调用FuncAnimation(fig,func,frames)得到动态排序图。网上可以找到各种年度季度排名的公开数据集,一些讲动态排序图的教程也给出了数据集。为了再降低数据获取门槛,我们直接随机生成简单的排名数据。

【几种有代表性的排序数据表 null 太难整理的,费时】

【其中的一种数据组织形式df 4】

假设我们有如下的数据表df,表示7位用户A~G各自在3月到12月的消费金额。现在要画出从3月到12月用户消费金额的排名变化。color列用来给各自标识颜色,画条形图和画制作动图所用接口和参数前面都讲过(包括上篇文章),直接用ax.barh(y,width,color)FuncAnimation(fig,func,frames)来绘制,条形图是从下往上画的,因此正序排序后正好是最高的柱在最上面,不需要额外调转,具体代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fig,ax=plt.subplots()
def race_bar(i):
idx=str(i)
wdf=df.sort_values(by=idx)
width=list(wdf[idx])
yw=list(wdf['tag'])
ax.clear()
rs=ax.barh(yw,width,color=wdf['color'])
ax.set_xlim(0,wdf[idx].max()+200)
ax.set_title('A to G Animation')
ax.text(wdf[idx].max()+20,1,'{0}'.format(i),fontsize=30) #绘制当前条形图对应时间周期
ax.tick_params(top=True, bottom=False,
labeltop=True, labelbottom=False)
c=0
for r in rs:
ax.annotate('{0}'.format(yw[c]),xy=(r.get_width()-40,r.get_y()+r.get_height()/2-0.16))
ax.annotate('{0}'.format(width[c]),xy=(r.get_width()+5,r.get_y()+r.get_height()/2-0.16))
c+=1
amt=anm.FuncAnimation(fig,race_bar,frames=range(3,13),interval=600)
#整体结构和插入排序一脉相承

绘制效果如图:

【排序图 6】

:为了更好地获得具有你追我赶、一同向前的效果,且防止数据变化太过跳脱,防止出现前一秒还是第一、突然掉到最后一名的剧烈变动情况,生成df时,不是全部使用随机函数生成随机数,此处使用的方法是第一次随机生成数据,下一帧的数据在当前数据基础上加[-50,100]的值,本处设定是当前数x[i]是偶数时,x[i+1]=x[i]+randint(20,200),奇数时x[i+1]=x+randint(-30,100)。生成数据集的代码如下:

1
2
3
4
5
6
df=pd.DataFrame({'tag':list('ABCDEFG'),'color':['#1EAFAE', '#A3FFFF', '#69FFFF', '#BA5C25', '#FFA069', '#9E5B3A', '#D7CE88']})
df['3']=df['tag'].apply(lambda x:random.randint(50,600)) #初始列

for i in range(4,13):
idx=str(i-1) #偶数增幅,奇数在原来基础上[-30,50+5*i]变动
df['{0}'.format(i)]=df[idx].apply(lambda x:x+random.randint(20,100+i*6) if x%2==0 else x+random.randint(-30,50+i*5))

动态折线图

换一种图表类型也不难。最近动态折线图也很火。因为df也具有时间属性,这次只用A、B、C三行的数据绘制动态折线图,改一下数据处理并将ax.bar()换成ax.plot,成果如图。

【图 7】

绘制代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fig,ax=plt.subplots()
def race_line(i):
k=['A','B','C'] #只取3个人的数据
x=range(3,i+1)
ax.clear()
for ki in k:
wdf=df.loc[df['tag']==ki]
#筛选出画折线的x,y
y=[list(wdf.loc[wdf['tag']==ki][str(j)])[0] for j in x]
ax.plot(x,y,color=list(wdf['color'])[0])
ax.text(i+0.2,y[-1]+2,'{0}:{1}'.format(ki,y[-1]))
ax.set_xlim(3,i+3)
ax.set_title('ABC Lines Animation')

ax.tick_params(top=True, bottom=False,left=False,right=True,
labelright=True,labelleft=False,
labeltop=True, labelbottom=False)

amt=anm.FuncAnimation(fig,race_line,frames=range(6,13),interval=500)
amt.save('lines-animation-1.gif') #把动图保存为gif文件

绘制三维动态图也是同样的套路,建画布时加上projection="3d"参数,绘图时参数从[x,y]变成[x,y,z],其他按框架来做。

形状绘制深入

在上篇的图表元素调校部分简单提到了在画布上加椭圆、矩形的代码,这里再细化一下Matplotlib可以绘制的形状。整理如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#绘制基本形状的框架,以圆形为例
import matplotlib.patches as mpatches
from matplotlib.collections import PatchCollection
fig, ax = plt.subplots(subplot_kw=dict(aspect="equal")) #设置横纵坐标单位长度一致,也可写 plt.axis('equal')

patches = [] #需渲染的形状集合

circle = mpatches.Circle((50,50),20) #初始化一个圆心在(50,50),半径为20的圆形
patches.append(circle)

pcc= PatchCollection(patches,color="#69FFFF") #因patches整合到了PatchCollection对象里,在Circle里写颜色参数好像没用,需要把color传到这里
ax.add_collection(pcc)
ax.set_ylim(0,100) #设置x,y的展示范围
ax.set_xlim(0,100)
  • .Circle(xy,radius,**kwargs): 绘制一个圆形,第一个参数是圆心坐标,可以传数组或元组,x、y不是单独传的;radius是圆的半径;后续的参数有图形标签(label)、线风格(linestyle)、圆边框宽度(linewidth)、图层顺序(zorder)等;

  • .Ellipse(xy,width,height,angle,**kwargs): 以xy为圆心绘制一个椭圆。Circle()的第二个参数是半径,椭圆需要长轴长度和短轴长度,也就是width和height,angle控制旋转角度,逆时针,按度计算,例如angle=90时,原来一个扁的椭圆就变成了长的椭圆,转了90度;其他参数和Circle()基本一致,下面也不再重复。

  • .Wedge(center, r, theta1, theta2, width, **kwargs): 楔形,像劈掉一部分的圆,是饼图的那一块块饼,可以猜测用pie()绘制饼图时调用了Wedge;center对应圆的xy,即圆心坐标;r是半径,只绘制从theta1到theta2之间的圆形,交换t1和t2可以得到饼的另一个部分,width默认是None,当设置了width会从r-width的部分开始画,得到环状图;

  • .Rectangle(xy,width,height,angle=,**kwargs): 和椭圆的参数写法惊人一致,不同之处在于矩形的xy是左下角坐标而不是中心的坐标;

  • .RegularPolygon(xy,numVertices,radius,orientation,**kwargs): 绘制正多边形xy是图形的中心点,numVertices是顶点个数,如numVertices=5是正五边形;radius:从图形中心xy到顶点的距离;orientation:旋转的度数,是弧度制;

  • .Arrow(x,y,dx,dy, width, **kwargs): 绘制一个箭头,x:箭头尾部的x坐标,y:箭头尾部的y坐标;dx:箭头指向位置距离x的长度,dy同理,width是箭头的宽度,默认值是1,当形状用一般设置得大一些。另外还有hatch参数可以设置箭头的底纹效果;

  • .PathPatch(path, **kwargs): 绘制一系列坐标构成的路径,是非常强大的接口,绘制各种不规则的形状、图标、贝塞尔曲线等一般都直接用Path的接口,和Canvas本身path对象的规则基本一致,东西比较多,不好展开;

  • .FancyBboxPatch(xy,width,boxstyle='round',**kwargs): 边框效果更个性化的图形,前面3个参数就是矩形的参数,boxstyle控制绘制各种效果,boxstyle支持的有circle(圆边)、round(边缘钝化的矩形)、square(方边)、sawtooth(锯齿边)等。下面的整理更形象。

【图,更好地解释代码对应画的图形 常用绘制形状及参数 8】

基于上面的形状,这里复现一下绘制经典的数据科学维恩图。

画维恩图只需要Circle(xy,r)就够了,因patches整合到了PatchCollection对象里,在Circle里写颜色参数似乎没用,就把color从PatchCollection传入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#绘制基本形状的框架,以圆形为例
import matplotlib.patches as mpatches
from matplotlib.collections import PatchCollection
fig, ax = plt.subplots(subplot_kw=dict(aspect="equal")) #设置横纵坐标单位长度一致,也可写 plt.axis('equal')

patches = [] #需渲染的形状集合

circle = mpatches.Circle((50,50),20) #初始化一个圆心在(50,50),半径为20的圆形
patches.append(circle)

pcc= PatchCollection(patches,color="#69FFFF") #因patches整合到了PatchCollection对象里,在Circle里写颜色参数好像没用,需要把color传到这里
ax.add_collection(pcc)
ax.set_ylim(0,100) #设置x,y的展示范围
ax.set_xlim(0,100)

【数据科学经典维恩图 9】

另外一种画多个圆的方法是用ax.add_artist(ada),示例代码如下:

1
2
3
4
5
from mpl_toolkits.axes_grid1.anchored_artists import AnchoredDrawingArea
p1 = mpatches.Circle((15, 85), 78,fc='#1EAFAE',alpha=0.62) #(x,y),r,fcolor,alpha
ada = AnchoredDrawingArea(100,100,0, 0,loc=10, pad=0., frameon=False) #width( in pixels), height, xdescent, ydescent, loc, pad=0.4,
ada.drawing_area.add_artist(p1)
ax.add_artist(ada)

matplotlib.image的接口中有图像的读取接口,ax.imshow(mpimg.imread('imagename.png'))可以读取图片并显示,因此Matplotlib即能画饼柱折点等图形,也能画更底层的线段、楔形、多边形,还能读取图片进行处理。常用需求有给图片加文本水印、给图形加图片(如画各国动态排序柱图时给对应柱画上国旗)、用形状裁剪图片等;

极坐标

plt.subplot()其中有一个参数是projection,表示所使用的坐标系统,之前画三维图的时候用到projection='3d',画心脏线函数的时候用到了projection='polar',再来细化一下极坐标下的绘图。

1
2
3
4
#极坐标系下的可视化和直角坐标没多少改变
ax=plt.subplot(111,projection='polar')
x=[5,4,3,2,1]
ax.plot(x)

pyplot.subplot支持的坐标系统有’rectilinear’、’polar’、’lambert’、’hammer’、 ‘mollweide’、’aitoff’等 看有哪些坐标系统,主要在3d绘图、极坐标绘图、地图投影等场景下使用。正如rectilinear直角坐标系下确定一个位置用[x,y],在极坐标系下定位一个位置通过[theta,r],theta表示正方向旋转的弧度,r表示距离原点的直线距离(也称r轴为极径)。

【直角坐标与极坐标的对比】

极坐标系可视化有一些基本的属性可以设置。

  • ax.set_theta_direction(-1): 设置极坐标角度的正方向,默认值是1,表示逆时针方向,设置为-1时是顺时针方向;
  • ax.set_theta_zero_location(loc,offset=0): 设置极坐标洗0°的位置,默认是loc是’E’,表示正东方向,loc有八种选择:(“N”, “NW”, “W”, “SW”, “S”, “SE”, “E”, “NE”),offset表示在loc的基础上按照正方形偏移多少度数;
  • ax.set_thetagrids(angles,labels,fmt):设置极坐标角度网格线上标签的显示,labels是要显示的标签,angles是标签所在对应的角度(注意不是弧度),angles值的范围应该取[0,360], 默认显示0°、45°、90°、135°、180°、225°、270°、315°的网格线。类似于直角坐标系下的ax.set_xticklabels(df['x'])
  • ax.set_rgrids(radii,labels): 设置极径网格线和标签显示,和上面ax.set_thetagrids效果对应;
  • ax.set_rlabel_position(value): 设置极径标签显示位置,value为标签所要显示在的角度;
  • ax.set_rlim(0,30): 设置极径显示范围,对应直角坐标下的set_ylim(0,30)
  • ax.set_rscale(): 设置极径方向所用的比例尺,默认是’linear’表示是线性变化,可以设置为’log’得到对数比例尺;

很多我们常见的图将其转到极坐标系下会有惊艳的效果,例如饼图可以认为是极坐标系下的柱状图,将柱的高度映射为楔形的弧度;玫瑰图可以是极坐标系下的堆积柱状图,柱的高度映射为r及弧度theta的占比;雷达图可以是极坐标系下的折线图。

【几种图的变换】

我们用极坐标绘制南丁格尔玫瑰图的时候,可以再次复习柱状图bar的参数,代码如下。

1
2
3
4
5
6
7
8
9
10
y=[42, 142, 61, 119, 68]
z=[77, 46, 65, 81, 50]
ax=plt.subplot(111, projection='polar')
#对y和z进行一些运算以适应弧度制
yw=[i*2*3.1416/sum(y) for i in y]
xw=[sum(y[0:i])*2*3.1416/sum(y) for i in range(len(y))]
yzw=[i*2*3.1416/sum(z) for i in z]
zw=[sum(z[0:i])*2*3.1416/sum(z) for i in range(len(z))]
ax.bar(xw,y,width=yw,align='edge',linewidth=1,edgecolor='k') #设置x对应柱的边缘开始画而不是中心了
ax.bar(xw,y,width=yw,bottom=y,align='edge',linewidth=1,edgecolor='k') #设置柱的边缘颜色以区分各个饼

【南丁格尔玫瑰图绘制结果】

转换的过程需要对数据进行换算,这算一个Matplotlib不够智能的设置,不能直接通过换坐标系统的语句实现数据的一个换算,例如将原先的x轴自动换算到[0,2pi]绘制美观的图表,针对这种换坐标系实现堆积的方法,基于属性映射的可视化语法,会将换算细节封装好,能直接使用出图。

Matplotlib简单交互

Matplotlib画静态图非常专业,同时它也能通过事件监听实现基础的交互功能。Matplotlib通过plt.connect(s, func)实现对鼠标和键盘等事件的监听,s表示plt会关联的事件,如s=’button_press_event’表示按下鼠标时会出发func函数,在func里写入触发事件后的处理逻辑,相应事件还有:{‘key_press_event’:’按下按键’,’key_release_event’:’松开按键’,’resize_event’:’改变窗体大小’,’close_event’:’关闭窗体’}等。官网给了两个例子分别表示按下按钮时print相应的坐标以及按键时触发保存图片等交互。基于Matplotlib的接口要实现流畅复杂的交互代码会很复杂。

自己简单实现了一下当鼠标点击到柱状图的柱子上时会高亮当前柱并显示当前柱对应的值。效果如下:

【图】

具体代码如下,jupyter notebook环境似乎不支持Matplotlib的交互操作,需依赖GUI环境,因此运行结果是通过脚本运行得到的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from matplotlib.backend_bases import MouseButton
import matplotlib.pyplot as plt

xw=list('ABCDE')
yw=[42, 142, 61, 119, 68]
fig, ax = plt.subplots()

rs=ax.bar(xw,yw,color='#1EAFAE') #绘制初始的条形图
ax.set_title('plt.connect demo')

def on_click_bar(event):
if event.button is MouseButton.LEFT:
x, y = event.xdata, event.ydata
print('w',x,y)
for r in range(len(rs)):
if x>rs[r].get_x() and x<rs[r].get_x()+rs[r].get_width() and y<rs[r].get_height():
print('q',x,y)
ax.clear()
c=['#BA5C25' if i==r else '#1EAFAE' for i in range(len(rs))]
ax.bar(xw,yw,color=c)
ax.text(x,y,'{0}'.format(rs[r].get_height()))
ax.set_title('plt.connect demo')
fig.canvas.draw_idle() #刷新当前画布

plt.connect('button_press_event', on_click_bar) #监听
plt.show()

关于Matplotlib库,还可以深入的有:

  • 图形的布尔运算、Path的具体规则等;
  • 渐变的颜色调节;
  • 地图投影及basemap的使用;
  • 根据三维数据绘制等高线ax.contour(X, Y, Z,levels)

Matplotlib的各模块内容细化拆解会有非常多的内容,市面上有挺多专门讲mat可视化的厚书,如果只考虑快速使用和了解几大模块的话,Matplotlib的精要内容是可以15分钟学会的,个人认为在知道了基本可视化框架后,了解折线图。柱状图、饼图、直方图等的绘制方法和基本参数,再学会添加文本、调节坐标轴,会通过双坐标轴和子图画多张图,最后了解下动态图和事件监听做基础交互。其他内容和细节通过需求驱动深入学。

参考资料

  • 刘新宇.算法新解[M].人民邮电出版社,2017:19.

各种可绘制的形状整理:https://matplotlib.org/gallery/shapes_and_collections/artist_reference.html#sphx-glr-gallery-shapes-and-collections-artist-reference-py