优秀的编程知识分享平台

网站首页 > 技术文章 正文

Python 数据分析——matplotlib 坐标变换和注释

nanyue 2024-10-14 11:33:11 技术文章 24 ℃

一幅图表中涉及多种坐标系以及坐标变换,理解各种坐标系的含义并掌握其用法才能随心所欲地使用matplotlib绘制出理想效果的图表。本节以图表中的文字、箭头和标注为例介绍各种坐标系及其变换。

def func1(x): ?
return 0.6*x + 0.3

def func2(x): ?
return 0.4*x*x + 0.1*x + 0.2

def find_curve_intersects(x, y1, y2):
d = y1 - y2
idx = np.where(d[:-1]*d[1:]<=0)[0]
x1, x2 = x[idx], x[idx+1]
d1, d2 = d[idx], d[idx+1]
return -d1*(x2-x1)/(d2-d1) + x1

x = np.linspace(-3,3,100) ?
f1 = func1(x)
f2 = func2(x)
fig, ax = plt.subplots(figsize=(8,4))
ax.plot(x, f1)
ax.plot(x, f2)

x1, x2 = find_curve_intersects(x, f1, f2) ?
ax.plot(x1, func1(x1), "o")
ax.plot(x2, func1(x2), "o")

ax.fill_between(x, f1, f2, where=f1>f2, facecolor="green", alpha=0.5) ?

from matplotlib import transforms
trans = transforms.blended_transform_factory(ax.transData, ax.transAxes)
ax.fill_between([x1, x2], 0, 1, transform=trans, alpha=0.1) ?

a = ax.text(0.05, 0.95, u"直线和二次曲线的交点",  ?
transform=ax.transAxes,
verticalalignment = "top",
fontsize = 18,
bbox={"facecolor":"red","alpha":0.4,"pad":10}
)

arrow = {"arrowstyle":"fancy,tail_width=0.6",
"facecolor":"gray",
"connectionstyle":"arc3,rad=-0.3"}

ax.annotate(u"交点", ?
xy=(x1, func1(x1)), xycoords="data",
xytext=(0.05, 0.5), textcoords="axes fraction",
arrowprops = arrow)

ax.annotate(u"交点", ?
xy=(x2, func1(x2)), xycoords="data",
xytext=(0.05, 0.5), textcoords="axes fraction",
arrowprops = arrow)

xm = (x1+x2)/2
ym = (func1(xm) - func2(xm))/2+func2(xm)
o = ax.annotate(u"直线大于曲线区域", ?
xy =(xm, ym), xycoords="data",
xytext = (30, -30), textcoords="offset points",
bbox={"boxstyle":"round", "facecolor":(1.0, 0.7, 0.7), "edgecolor":"none"},
fontsize=16,
arrowprops={"arrowstyle":"->"}
)

程序的输出如图1所示。在图1中演示了下面列出的标注效果:

图1 为图表添加各种注释元素

·用两个小圆点表示直线和曲线的两个交点。

·对两个交点之间、位于直线和曲线之间的面积进行了填充。

·使用一个高为整个子图高度、左右边位于两个交点的矩形表示两个交点之间的区间。

·在图1的左上角放置了说明文字。

·对两个交点和填充面积使用了带箭头的注释说明。

首先,?定义了两个函数func1和func2,它们分别是计算一条直线和一条二次曲线的函数。?然后计算这两个函数在区间(-3, 3)上的值,并且调用plot()绘制成曲线图。

?为了标出两个交点,我们用find_curve_intersects()计算两条曲线f1和f2的交点所对应的X轴坐标x1和x2。交点处的小圆点仍然使用plot()进行绘制,这时所传递的X-Y轴的数据为单一的数值,并且以'o'为样式进行绘图。

如何计算两条曲线的交点

当两条曲线的Y轴坐标值y1和y2使用相同的X轴坐标数组x计算时,很容易计算它们的交点。首先计算两条曲线在Y轴的差值d = y1-y2,然后找到符号相反的两个连续的差值的下标idx和idx + 1。计算直线(x[idx],d[idx])-(x[idx+1],d[idx+1])和X轴的交点就可得到两条曲线交点的X轴坐标xc。如果要计算交点的Y轴坐标,只需要调用np.interp(xc, x, y1)对曲线进行线性插值即可。

?接下来调用fill_between()绘制X轴上在两个交点之间、Y轴上在两条曲线之间的面积部分,并通过facecolor和alpha参数指定填充的颜色和透明度。fill_between()的调用参数如下:

fill_between(x, y1, y2=0, where=None)

其中,x参数是长度为N的数组,y1和y2参数是长度为N的数组或单个数值。当y1或y2为单个数值时,它们相当于一个长度为N、元素数值都相同的数组。fill_between()将填充Y轴在y1和y2之间的部分。如果where参数为None,就对数组x中的所有元素进行填充;如果where是一个布尔数组,则只填充其中True所对应的部分。程序中的数组x的取值范围为(-3, 3),由于设置了条件where = f1>f2,因此只绘制直线在二次曲线之上的部分。

?绘制X轴上在两个交点之间的矩形区域;?用text()在图表中添加说明文字;?最后用annotate()为图表添加三个带箭头的注释。

为了真正理解程序的细节,首先需要了解matplotlib中坐标变换的工作原理。

一、4种坐标系

在matplotlib所绘制的一幅图表中,有4种坐标系:

·数据坐标系:它是描述数据空间中位置的坐标系,例如对于图1,它的数据坐标系的范围为X轴在(-3, 3)之间,Y轴在(-2, 5)之间。

·子图坐标系:描述子图中位置的坐标系,子图的左下角坐标为(0, 0),右上角坐标为(1, 1)。

·图表坐标系:一幅图表可以包含多个子图,并且子图周围都有一定的余白,因此还需要用图表坐标系描述图表显示区域中的某个点,图表的左下角坐标为(0, 0),右上角坐标为(1, 1)。

·窗口坐标系:它是绘图窗口中以像素为单位的坐标系。左下角坐标为(0, 0),右上角坐标为(width, height)。其中的width和height分别是以像素为单位的绘图窗口的内宽和内高,不包括标题栏、工具条以及状态栏等部分。

Axes对象的transData属性是数据坐标变换对象,transAxes属性是子图坐标变换对象。Figure对象的transFigure属性是图表坐标变换对象。

通过上述坐标变换对象的transform()方法,可以将此坐标系下的坐标转换为窗口坐标系中的坐标。下面的程序计算数据坐标系中的坐标点(-3, -2)和(3, 5)在绘图窗口中的坐标:

print type(ax.transData)
ax.transData.transform([(-3,-2), (3,5)])
<class 'matplotlib.transforms.CompositeGenericTransform'>
array([[  80.,   32.],
[ 576.,  288.]])

下面的程序计算子图坐标系中的坐标点(0, 0)和(1, 1)在绘图窗口中的位置,得到的结果和上面的相同。即子图的左下角坐标(0, 0)和数据坐标系中的坐标(-3, -2)在屏幕上是一个点。观察图1可以知道这显然是正确的。?

ax.transAxes.transform([(0,0), (1,1)])
array([[  80.,   32.],
[ 576.,  288.]])

最后计算图表坐标系中坐标点(0, 0)和(1, 1)在绘图窗口中的位置,可以看出绘图区域的宽为640个像素,高为320个像素:?

fig.transFigure.transform([(0,0), (1,1)])
array([[   0.,    0.],
[ 640.,  320.]])

通过坐标变换对象的inverted()方法,可以获得它的逆变换对象。例如下面的程序计算绘图窗口中的坐标点(320, 160)在数据坐标系中的坐标,结果为(-0.09677419, 1.5):

inv = ax.transData.inverted()
print type(inv)
inv.transform((320, 160))
<class 'matplotlib.transforms.CompositeGenericTransform'>
array([-0.09677419,  1.5       ])

请读者仔细观察程序所输出的图表,子图的上下余白相同,而左侧余白略大于右侧余白,因此绘图区域的中心点(320, 160)并不是数据区域的中心点(0, 1.5)。

当调用set_xlim()修改子图所显示的X轴范围之后,它的数据坐标变换对象也同时发生了变化:?

print ax.set_xlim(-3, 2) # 设置X轴的范围为-3到2
print ax.transData.transform((3, 5)) # 数据坐标变换对象已经发生了变化
(-3, 2)
[ 675.2  288.]

下面回头看看图1中绘制矩形区间的程序:

from matplotlib import transforms
trans = transforms.blended_transform_factory(ax.transData, ax.transAxes)
ax.fill_between([x1, x2], 0, 1, transform=trans, alpha=0.1)

矩形区间使用fill_between()绘制。由于所绘制矩形的左右两边要始终经过两个交点,因此矩形的X轴坐标必须使用数据坐标系中的坐标:x1和x2。而由于矩形的高度始终充满整个子图的高度,因此矩形的Y轴坐标必须是子图坐标系中的坐标:0和1。

使用axvspan()和axhspan()可以快速绘制垂直方向和水平方向上的区间。

程序中,使用blended_transform_factory()创建这种混合坐标系。它的两个参数都是坐标变换对象,它从第一个参数获得X轴的坐标变换,从第二个参数获得Y轴的坐标变换。因此它所返回的坐标变换对象trans的X轴使用数据坐标系,而Y轴使用子图坐标系。程序中,将混合坐标变换对象trans传递给fill_between()的transform参数,这样所绘制的填充区域就能始终保持左右边通过两个交点,而上下边位于子图边框之上。

二、坐标变换的流水线

从一个坐标系变换到另一个坐标系,中间需要经过几个步骤。而且数据坐标系不一定是笛卡尔坐标系,它可能是极坐标系或对数坐标系。因此坐标系的变换并不是简单的二维仿射变换(2D Affine Transformation)。让我们从最简单的图表坐标变换对象transFigure开始,介绍matplotlib的坐标变换是如何进行的。

通过本书提供的GraphvizMPLTransform可以将坐标变换对象显示为关系图,图2显示了fig.transFigure的内部结构。

图2 图表坐标变换对象的内部结构?

from scpy2.common import GraphvizMPLTransform
%dot GraphvizMPLTransform.graphviz(fig.transFigure)

这个坐标变换对象的内容有些复杂,它是一个BboxTransformTo对象,其中包含一个TransformedBbox对象,而TransformedBbox对象又包含一个Bbox对象和一个Affine2D对象:

·Bbox:定义一个矩形区域——[[x0, y0], [x1, y1]]。在本例中,矩形的两个顶点坐标分别为(0, 0)和(8 ,4),它是窗口的英寸大小,通过figsize参数传递给figure()。

·Affine2D:二维仿射变换对象,它是一个矩阵,通过它和齐次向量相乘得到变换之后的坐标。由于矩阵中只有对角线上的值不为零,因此该仿射变换只进行缩放变换。它将坐标(x, y)变换为(80*x, 80*y)。

仿射变换

二维空间的仿射变换矩阵的大小为3×3,为了进行仿射变换需要使用齐次坐标,即用三维向量(x, y, 1)表示二维平面上的点(x, y)。仿射变换就是仿射矩阵和向量的乘积。由于变换矩阵最下一行的数值始终是(0, 0, 1),因此有时也将它写成2×3的矩阵形式。

·TransformedBbox:将矩形区域通过仿射变换之后得到一个新的矩形区域。例子中,所得到的矩形区域的两个顶点为(0, 0)和(640, 320)。为了避免重复运算,它的_points属性缓存了这两个顶点的坐标。它正好是以像素点为单位的窗口的大小,因此仿射变换矩阵中的数值80实际上是Figure对象的dpi属性。

·BboxTransformTo:它是一个从单位矩形区域转换到指定的矩形区域的变换。在本例中,它是一个将矩形区域(0, 0)-(1, 1)变换到矩形区域(0, 0)-(640, 320)的坐标变换对象,因此它能将坐标从图表坐标系转换为窗口坐标系中的坐标。其_mtx属性缓存了该变换矩阵。

fig.transFigure中的仿射变换对象可以通过fig.dpi_scale_trans获得:?

fig.dpi_scale_trans == fig.transFigure._boxout._transform
True

接下来我们查看子图坐标变换对象的内容(内容结构参见图3):

图3 子图坐标变换对象的内部结构?

%dot GraphvizMPLTransform.graphviz(ax.transAxes)

ax.transAxes是一个BboxTransformTo对象,因此它也将(0, 0)-(1, 1)区域变换为另一个区域。而此区域是一个TransformedBbox对象,它是将矩形区域(0.125, 0.1)-(0.9, 0.9)通过fig.transFigure变换之后的区域。因此在transAxes对象内部使用了transFigure变换:

ax.transAxes._boxout._transform == fig.transFigure
True

而此变换中的矩形区域(0.125, 0.1)-(0.9, 0.9)是子图在图表坐标系中的位置:

ax.get_position()
Bbox('array([[ 0.125,  0.1  ],\n       [ 0.9  ,  0.9  ]])')

子图在窗口坐标系中的矩形区域为:

ax.transAxes._boxout.bounds
(80.0, 31.999999999999993, 496.0, 256.0)

因此ax.transAxes实际上是一个将矩形区域(0,0)-(1,1)变换到矩形区域(80.0,32)-(496.0, 256.0)的坐标变换对象。

最后我们观察数据坐标系的变换对象ax.transData(内部结构参见图4)。它由ax.transScale、ax.transLimits和ax.transAxes共同构成,因此先看看ax.transLimits和ax.transScale的内容。transLimits是一个BboxTransformFrom对象,它是一个将指定的矩形区域变换为(0,0)-(1,1)矩形区域的变换对象。

图4 数据坐标变换对象的内部结构?

%dot GraphvizMPLTransform.graphviz(ax.transLimits)

而transLimits的源矩形区域为一个TransformedBbox对象,它是一个将矩形区域(-3, -2)-(2, 5)通过坐标变换之后的矩形区域。而此处的变换由TransformWrapper对象定义,在图4中它是一个恒等变换。因此transLimits的最终效果就是将矩形区域(-3,-2)-(2, 5)变换为矩形区域(0, 0)-(1, 1):

print ax.transLimits.transform((-3, -2))
print ax.transLimits.transform((2, 5))
[ 0. 0.]
[ 1. 1.]

而矩形区域(-3, -2)-(2, 5)由X轴和Y轴的显示范围决定:

print ax.get_xlim() # 获得X轴的显示范围
print ax.get_ylim() # 获得Y轴的显示范围
(-3.0, 2.0)
(-2.0, 5.0)

由于transLimits将数据坐标系的显示范围变换为单位矩形,而transAxes将单位矩形变换为以像素为单位的窗口矩形范围,因此这两个变换的综合效果就是将数据坐标变换为窗口坐标。可以用“+”号将两个变换连接起来创建一个新的变换对象,例如ax.transLimits + ax.transAxes表示先进行ax.transLimits变换,然后进行ax.transAxes变换,变换对象就像流水线上生产产品一样,一步一步地对坐标点进行变换。下面的程序比较它和ax.transData的变换结果:

t = ax.transLimits + ax.transAxes
print t.transform((0,0))
print ax.transData.transform((0,0))
[ 377.6         105.14285714]
[ 377.6         105.14285714]

为了支持不同比例的坐标轴,transData中还包括一个transScale变换,即transData = transScale + transLimits + transAxes。本例中transScale是一个恒等变换,因此ax.transLimits + ax.transAxes和ax.transData的变换效果一样:

ax.transScale
TransformWrapper(BlendedAffine2D(IdentityTransform(),IdentityTransform()))

当使用semilogx()、semilogy()以及loglog()等绘图函数绘制对数坐标轴的图表时,或者使用Axes的set_xscale()和set_yscale()等方法将坐标轴设置为对数坐标时,transScale就不再是恒等变换了,其内部结构如图5所示。

图5 X轴为对数坐标时transScale对象的内部结构

由于本例中X轴的取值范围包含负数,因此如果将X轴改为对数坐标,并且重新绘图,会产生很多错误信息。

ax.set_xscale("log") # 将X轴改为对数坐标
%dot GraphvizMPLTransform.graphviz(ax.transScale)
ax.set_xscale("linear") # 将X轴改为线性坐标

三、制作阴影效果

下面用上节介绍的坐标变换绘制带阴影效果的曲线。完整程序如下,效果如图6所示:

图6 使用坐标变换绘制的带阴影的曲线

fig, ax = plt.subplots()
x = np.arange(0., 2., 0.01)
y = np.sin(2*np.pi*x)

N = 7 # 阴影的条数
for i in xrange(N, 0, -1):
offset = transforms.ScaledTranslation(i, -i, transforms.IdentityTransform()) ?
shadow_trans = plt.gca().transData + offset ?
ax.plot(x,y,linewidth=4,color="black",
transform=shadow_trans,  ?
alpha=(N-i)/2.0/N)

ax.plot(x,y,linewidth=4,color='black')
ax.set_ylim((-1.5, 1.5))

首先使用循环绘制N条透明度和偏移量逐渐变化的曲线,然后绘制实际的曲线,以实现阴影效果。

?offset是一个ScaledTranslation对象,它的前两个参数决定了X轴和Y轴的偏移量,而第三个参数是一个坐标变换对象,经过它变换之后,再进行偏移变换。由于程序中的第三个参数是一个恒等变换,因此offset实际上是一个单纯的偏移变换:对X轴坐标增加i,对Y轴坐标减少i。

下面查看i为1时的offset:

offset.transform((0,0)) # 将(0,0)变换为(1,-1)
array([ 1., -1.])

?阴影曲线的坐标变换由shadow_trans完成,它由数据坐标变换对象transData和offset组成。

print ax.transData.transform((0,0)) # 对(0,0)进行数据坐标变换
print shadow_trans.transform((0,0)) # 对(0,0)进行数据坐标变换和偏移变换
[  60. 120.]
[  61. 119.]

?最后通过参数transform将shadow_trans传递给plot()绘图。由于shadow_trans是在完成数据坐标到窗口坐标的变换之后,再进行偏移变换,因此无论当前的缩放比例如何,阴影效果将始终保持一致。

四、添加注释

在pyplot模块中提供了两个绘制文字的函数:text()和figtext()。它们分别调用当前Axes对象和当前Figure对象的text()方法进行绘图。text()默认在数据坐标系中添加文字,而figtext()则默认在图表坐标系中添加文字。可以通过transform参数改变文字所在的坐标系,下面的程序演示了在数据坐标系、子图坐标系以及图表坐标系中添加文字:

x = np.linspace(-1,1,10)
y = x**2

fig, ax = plt.subplots(figsize=(8,4))
ax.plot(x,y)

for i, (_x, _y) in enumerate(zip(x, y)):
ax.text(_x, _y, str(i), color="red", fontsize=i+10) ?

ax.text(0.5, 0.8, u"子图坐标系中的文字", color="blue", ha="center",
transform=ax.transAxes) ?

plt.figtext(0.1, 0.92, u"图表坐标系中的文字", color="green") ?

?由于没有设置transform参数,text()默认在数据坐标系中创建文字,这里通过fontsize参数修改文字的大小。?通过transform参数将文字的坐标变换改为ax.transAxes,因此文字在子图坐标系中。ha参数为'center'表示坐标点(0.5,0.8)在水平方向上是文字的中心,ha是horizontalalignment的缩写,其含义是水平对齐。?调用figtext()在图表坐标系中添加文字。

程序的输出如图7所示。请读者使用缩放和平移工具改变子图的显示范围,你会发现数据坐标系中的文字将跟随曲线变动,而其他两个坐标系中的文字位置不变。单击绘图窗口工具栏中的倒数第二个图标按钮,打开“Subplot Configuration Tool”对话框,调节top、right、bottom和left等参数,你会发现子图坐标系中的文字也会跟着改变位置,水平方向上它和子图的中心始终保持一致。而图表坐标系中文字的位置,只有在改变窗口大小时才会发生变化。

图7 三个坐标系中的文字

绘制文字的函数还有许多关键字参数用于设置文字、外框的样式,请读者参考matplotlib的用户手册,这里就不再详细介绍了。

通过pyplot模块的annotate()绘制带箭头的注释文字,其调用参数如下:

annotate(s, xy, xytext=None, xycoords='data', textcoords='data', arrowprops=None, ...)


其中s参数是注释文本,xy是箭头所指处的坐标,xytext是注释文本所在的坐标。xycoords和textcoords分别指定箭头坐标和注释文本坐标的坐标变换方式。

带箭头的注释需要指定两个坐标:箭头所指处的坐标和注释文字所在的坐标。而这两个坐标可以使用不同的坐标变换。参数xycoords和textcoords都是字符串,它们可以有表1所示的几种选项:

表1 属性值与相应的坐标变换方式

属性值

坐标变换方式

figure points

以点为单位,相对于图表左下角的坐标

figure pixels

以像素为单位,相对于图表左下角的坐标

figure fraction

图表坐标系中的坐标

axes points

以点为单位,相对于子图左下角的坐标

axes pixels

以像素为单位,相对于子图左下角的坐标

axes fraction

子图坐标系中的坐标

data

数据坐标系中的坐标

offset points

以点为单位,相对于点xy的坐标

polar

数据坐标系中的极坐标

其中'figure fraction'、'axes fraction'和'data'分别表示使用图表坐标系、子图坐标系和数据坐标系中的坐标变换对象。由于图表和子图坐标系都是正规化之后的坐标,使用起来不太方便,因此对于图表和子图还分别提供了以点为单位和以像素为单位的坐标变换方式。点和像素的单位类似,但是它不会随着图表的dpi属性值而发生变化,它始终以每英寸72个点进行计算。

上述几种坐标变换都以固定的点为原点进行变换,有时我们希望以距离箭头的偏移量指定文字的坐标,这时可以使用'offset points'选项。

在图1中,所有注释的箭头坐标都采用'data',因此无论如何放大或平移绘图区域,箭头始终指向数据坐标系中的固定点。而注释文本“交点”的坐标变换方式采用'axes fraction',因此“交点”始终保持在子图中的固定位置。而“直线大于曲线区域”注释文本的坐标采用'offset points'变换,因此文字和箭头的相对位置始终保持不变。

最后,arrowprops参数是一个描述箭头样式的字典。关于注释样式的详细配置请参考matplotlib的相关文档。

Tags:

最近发表
标签列表