本文是阅读《科学可视化:Python和Matplotlib》(英文原名:《Scientific Visualization: Python + Matplotlib》)的记录,书和代码均为开源。
第0章 引言(Introduction)
Python的科学可视化包罗万象,面对如此多的选择,可能很难找到最合适自己需求的库,因为你可能甚至没有意识有这个库的存在。
在进行可视化之前,需要思考几个问题:
- 是否针对桌面或网页渲染?
- 是否需要复杂的3D渲染?
- 是否需要出版级别的图像质量?
- 是否有非常大的数据?
- 是否有活跃的社区?
- 是否有文档和教程?
pyviz网站提供了最新的活跃的有关Python可视化的工具列表。
本书共分为4个部分。
- 第1部分介绍了Matplotlib库的基本原理,包括回顾构成图像的不同部分,不同的坐标系,可用的尺度和投影,以及一些与排版和色彩有关的概念。
- 第2部分是图像的实际设计。在介绍了一些生成更好图像的简单规则之后,我们将继续解释Matplotlib默认值和样式系统,再进入图像布局组织。然后,我们将探讨不同的绘图类型,看看如何用不同的元素装饰一个图像。
- 第3部分介绍更高级的概念,即3D图像、优化、动画和工具包。
- 第4部分是展示案例的收集和分析。
第1章 基础原理(Fundamentals)
1.1 剖析图像(Anatomy of a figure)
Matplotlib图像构成图,经典中的经典
1.1.1 组成图像的元素(Elements)
- Figure:可以指定大小(
figsize
)、背景颜色(facecolor
)和标题(suptitle
)。当你保存图像时,背景颜色不会被使用,因为保存图像的savefig
函数也有一个facecolor
参数(默认为白色),它会覆盖图像的背景颜色。如果不想要任何背景(即透明),可以在保存图形时指定transparent=True
。 - Axes:是将要渲染数据的区域,也被称为子图(subplot)。每个 Figure 可以有一个到多个Axes,每个Axes通常由四条边(spine)(左、上、右、下)包围。每条 spine 都可以装饰有主刻度线(major tick)和次刻度线(minor tick)(指向可为向内或向外)、刻度值(tick label)和标签(label)。默认情况下,matplotlib 只装饰左和下 spine。
- Axis:被装饰过的 spine 被称为轴(axis)。水平方向为 x 轴(xaxis),垂直方向为 y 轴(yaxis),每个由 spine、主次刻度线(major and minor ticks)、主次刻度值(major and minor ticks labels)和轴标签(axis label)组成。
- Spines:是连接轴刻度标记(axis tick marks)和数据区域边界的线。可以放置在任意位置,可以是可见的或不可见的。
- Artist:图像上的一切,包括图(Figure)、Axes、Axis,包括文本(Text)对象、二维线(Line2D)对象、集合(collection)对象、便签(Patch)对象。当图像被渲染时,所有的 artists 都被绘制到画布上。一个给定的 artist 只能在一个 Axes 里。
1.1.2 图形元件(Graphic primitives)
一个绘图(Plot)由便签(Patch)、线(Line)和文本(Text)组成。Patch 可以非常小(比如标记(marker))或非常大(比如棒(bar)),也可以由很多形状(比如圆(circle)、矩形(rectangle)、多边形(ploygon)等);Line 可以非常细小(比如刻度线(ticks))或非常粗(比如 hatch)。Text 可以使用任何自己系统上有的字体,也能用 LaTeX \LaTeX LATEX 渲染数学。
上述每个图形元件(Graphic primitives)还有很多其他属性,如颜色(color)(填充色(facecolor)和边框色(edgecolor))、透明度(transparency)(从0到1)、样式(pattern)(如破折号线(dash))、风格(style)(如 cap styles)、特效(如阴影(shadow)或轮廓(outline))、平滑(真(True)或假(False))等。
任何元件(primitive)的一个重要属性是 zorder
属性,它表示元件的虚拟深度,如下图。其值用于在渲染前对元件从低到高的排序,于是能控制谁在谁后渲染。大多数 Artist 都有默认的 zorder 值。例如,spine、tick 和 tick label 一般你画的图的后面。
默认渲染顺序,自下而上
1.1.3 后端(Backend)
后端是负责实际绘图的渲染器和允许与图像交互的可选用户界面的组合。默认情况下,在使用 plt.show()
时会显示一个窗口。可以用以下代码获取默认和修改后端:
import matplotlib
print(matplotlib.get_backend()) # 获取后端
matplotlib.use("xxx") # 修改后端, 具体见下表
用下表中第一列替换 xxx
,会得到一个非交互式的图像,即不能在屏幕上显示而只能保存在磁盘上的图像。注意,即使选了 raster 类型的 Renderer,仍然可以将图像保存为 vector 类型,反之亦然。
可用的matplotlib渲染器
Renderer Type Filetype Agg raster Portable Network Graphic (PNG) PS vector Postscript (PS) vector Portable Document Format (PDF) SVG vector Scalable Vector Graphics (SVG) Cairo raster / vector PNG / PDF / SVG
如果想和图像有交互,必须把一个交互界面(见下表)和一个渲染器结合起来,比如 GTK3Cairo
或 WebAgg
。例如,要在浏览器中渲染,可用如下代码:
注意:导入 pyplot
前必须调用 use
函数!
import matplotlib
matplotlib.use('webagg') # 先用 use 函数
import matplotlib.pyplot as plt # 再导入 pyplot
plt.show()
可用的matplotlib交互界面
Interface Renderer Dependencies GTK3 Agg or Cairo PyGObject & Pycairo Qt4 Agg PyQt4 Qt5 Agg PyQt5 Tk Agg TkInter Wx Agg wxPython MacOSX — OSX (obviously) Web Agg Browser
一旦选择了交互后端,就可以决定在此交互方式下制图(每次 matplotlib 命令后更新图像):
plt.ion() # 开启交互模式
plt.plot([1,2,3]) # 显示绘图
plt.xlabel("X Axis") # 标签更新
plt.ioff() # 关闭交互模式
更多关于后端(backend)的知识,可在 matplotlib 官网上查看入门教程。
OSX 和 iterm2 终端(terminal)下一个有趣的后端是 imgcat 后端,它允许在终端内部直接渲染一个图像,模拟一种如下图所示的 jupyter notebook。
Matplotlib imgcat 后端
import numpy as np
import matplotlib
matplotlib.use("module://imgcat")
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(8,4), frameon=False)
ax = plt.subplot(2,1,1)
X = np.linspace(0, 4*2*np.pi, 500)
line, = ax.plot(X, np.cos(X))
ax = plt.subplot(2,1,2)
X = np.linspace(0, 4*2*np.pi, 500)
line, = ax.plot(X, np.sin(X))
plt.tight_layout()
plt.show()
对于其他终端,可能需要使用可能与 xterm 一起工作的 sixel 后端(未测试)。
1.1.4 尺寸和分辨率(Dimensions & resolution)
默认的 dpi=100。当在屏幕上显示图像时,可以使用默认的 dpi,因为此时用更大的 dpi 会使图像超出屏幕尺寸。当保存图像时,才使用更高的 dpi。可用如下代码:
def figure(dpi):
fig = plt.figure(figsize=(4.25,.2))
ax = plt.subplot(1,1,1)
text = "Text rendered at 10pt using %d dpi" % dpi
ax.text(0.5, 0.5, text, ha="center", va="center",
fontname="Source Serif Pro",
fontsize=10, fontweight="light")
plt.savefig("figure-dpi-%03d.png" % dpi, dpi=dpi)
figure(50), figure(100), figure(300), figure(600)
尽量将结果保存为 PDF,因为它是一种矢量格式,可以完美地适应任何分辨率。但是,即使将图像以矢量格式保存,对于无法矢量化的图像元素,仍然需要注明 dpi。
为了帮助可视化图像的精确尺寸,可以在图像中添加一个标尺(ruler),使其显示当前图像的尺寸,如下图所示。如果手动调整图像的大小,会看到图像的实际尺寸发生了变化,而如果只改变 dpi,尺寸将保持不变。代码为:
import ruler
import numpy as np
import matplotlb.pyplot as plt
fig,ax = plt.subplots()
ruler = ruler.Ruler(fig)
plt.show()
标尺
1.1.5 练习(Exercise)
- 尝试制作一个给定(且精确)像素大小的图像(例如 512×512 像素)。如何指定尺寸和保存图形?
- 目标是制作一个显示双轴的下图,一个英寸,一个厘米。难点在于,我们希望打印时厘米和英寸的物理位置正确。这需要一些简单的计算来找到合适的尺寸,并需要一些尝试和错误来制作实际的图像。不要过于关注所有的细节,关键是要把尺寸弄对。
- 尝试复现下图。每条曲线都部分地覆盖了其他曲线,因此为每条曲线设置一个合适的 zorder 非常重要,这样渲染就不会受到绘图顺序的影响。对于实际曲线,可以从下面的代码开始:
def curve():
n = np.random.randint(1,5)
centers = np.random.normal(0.0,1.0,n)
widths = np.random.uniform(5.0,50.0,n)
widths = 10*widths/widths.sum()
scales = np.random.uniform(0.1,1.0,n)
scales /= scales.sum()
X = np.zeros(500)
x = np.linspace(-3,3,len(X))
for center, width, scale in zip(centers, widths, scales):
X = X + scale*np.exp(- (x-center)*(x-center)*width)
return X
1.2 坐标系(Coordinate systems)
1.2.1 坐标系
在任意一个 matplotlib 图中,至少有两个不同的坐标系在任何时候都是共存的。其中一个与图形相关(FC,Figure Coordinate),其余的与每个独立的图形相关(DC,Data Coordinate)。每个坐标系以标准化(NxC,Normalized)或原始版本(xC,Native)存在,如下两图所示。
使用笛卡尔投影的图形内共存的坐标系 使用极坐标投影的图形内共存的坐标系
为了实现从一个坐标系到另一个坐标系的转换,matplotlib 提供了一组 transform
函数:
fig = plt.figure(figsize=(6, 5), dpi=100)
ax = fig.add_subplot(1, 1, 1)
ax.set_xlim(0,360), ax.set_ylim(-1,1)
# FC : Figure coordinates (pixels)
# NFC : Normalized figure coordinates (0 → 1)
# DC : Data coordinates (data units)
# NDC : Normalized data coordinates (0 → 1)
DC_to_FC = ax.transData.transform
FC_to_DC = ax.transData.inverted().transform
NDC_to_FC = ax.transAxes.transform
FC_to_NDC = ax.transAxes.inverted().transform
NFC_to_FC = fig.transFigure.transform
FC_to_NFC = fig.transFigure.inverted().transform
在一些特定的点(角落)上对这些函数进行检验:
# Top right corner in normalized figure coordinates
print(NFC_to_FC([1,1])) # (600,500)
# Top right corner in normalized data coordinates
print(NDC_to_FC([1,1])) # (540,440)
# Top right corner in data coordinates
print(DC_to_FC([360,1])) # (540,440)
由于也有逆函数,我们可以建立自己的变换。例如,从原始数据坐标(DC)到标准化数据坐标(NDC):
# Native data to normalized data coordinates
DC_to_NDC = lambda x: FC_to_NDC(DC_to_FC(x))
# Bottom left corner in data coordinates
print(DC_to_NDC([0, -1])) # (0.0, 0.0)
# Center in data coordinates
print(DC_to_NDC([180,0])) # (0.5, 0.5)
# Top right corner in data coordinates
print(DC_to_NDC([360,1])) # (1.0, 1.0)
当使用笛卡尔投影时,标准化数据坐标和原始数据坐标之间的对应关系相当清晰。对于其他类型的投影,即使看起来不那么明显,事情也会一样。例如,考虑一个极坐标投影,想要画出外轴边界。在标准化数据坐标中,我们知道四个角落点的坐标,即 (0,0)、(1,0)、(1,1) 和 (0,1)。然后,将这些标准化数据坐标转换回原始数据坐标并绘制边界。但这有一个额外的困难,因为这些坐标超出了坐标轴的极限,需要使用 clip_on
参数告诉 matplotlib 忽略这个限制。代码和图如下。
fig = plt.figure(figsize=(5, 5), dpi=100)
ax = fig.add_subplot(1, 1, 1, projection='polar')
FC_to_DC = ax.transData.inverted().transform
NDC_to_FC = ax.transAxes.transform
NDC_to_DC = lambda x: FC_to_DC(NDC_to_FC(x))
P = NDC_to_DC([[0,0], [1,0], [1,1], [0,1], [0,0]])
plt.plot(P[:,0], P[:,1], clip_on=False, zorder=-10
color="k", linewidth=1.0, linestyle="--", )
plt.scatter(P[:-1,0], P[:-1,1],
clip_on=False, facecolor="w", edgecolor="k")
plt.show()
然而,在大多数情况下,并不需要显式地、而是隐式地使用这些转换函数。例如,考虑要在特定绘图上添加一些文本的情况。为此,需要使用文本(Text)函数,并指定要写什么和要显示文本的坐标。matplotlib 默认为在数据坐标(DC)中表示。因此,若要用一个不同的系统,在调用函数时需要显式地指定一个 transform
。例如,想在右下角加一个字母,可以写:
fig = plt.figure(figsize=(6, 5), dpi=100)
ax = fig.add_subplot(1, 1, 1)
ax.text(0.1, 0.1, "A", transform=ax.transAxes)
plt.show()
字母在距离左 spine 的10%和距离下 spine 的10%处放置。如果两个 spine 具有相同的物理大小(以像素为单位),则字母与左 spine 和下 spine 等距。但是,如果它们的大小不同,此情况就不存在了,结果也不会令人满意(见下图 A)。需要指定一个变换,该变换是由标准化数据坐标 (0,0) 和一个用图像原始单位(像素)表示的偏移量的组合。为此,需要建立自己的变换函数来计算偏移量(代码如下),结果见下图 B。文字现在被正确地定位,并且将独立于图像宽高比或数据限制而保持在正确的位置。
from matplotlib.transforms import ScaleTranslation
fig = plt.figure(figsize=(6, 4))
ax = fig.add_subplot(2, 1, 1)
plt.text(0.1, 0.1, "A", transform=ax.transAxes)
ax = fig.add_subplot(2, 1, 2)
dx, dy = 10/fig.dpi, 10/fig.dpi
offset = ScaledTranslation(dx, dy, fig.dpi_scale_trans)
plt.text(0, 0, "B", transform=ax.transAxes + offset)
plt.show()
当需要在 X 轴和 Y 轴上进行不同的变换时,可能会更复杂。考虑一个例子,在 X 轴标签下添加一些文本,轴标签的 X 位置用数据坐标表示,但如何将某些东西置于下图所示的下方?
文本的自然单位是点,因此想用点表示的 Y 偏移量来定位箭头。于是需要使用混合变换:
point = 1/72
fontsize = 12
dx, dy = 0, -1.5*fontsize*point
offset = ScaledTranslation(dx, dy, fig.dpi_scale_trans)
transform = blended_transform_factory(
ax.transData, ax.transAxes+offset)
还可以使用变换来实现完全不同的用法,如下图所示。为了得到这样的图像,重新编写 imshow
函数,对其进行平移、缩放和旋转操作,并以随机值调用该函数200次。
def imshow(ax, I, position=(0,0), scale=1, angle=0):
height, width = I.shape
extent = scale * np.array([-width/2, width/2,
-height/2, height/2])
im = ax.imshow(I, extent=extent, zorder=zorder)
t = transforms.Affine2D().rotate_deg(angle).translate(*
position)
im.set_transform(t + ax.transData)
转换是非常强大的工具,即使你在日常生活中不会太频繁地操作它们。但有一些情况你会乐于了解他们。你可以通过 matplotlib 网站上的转换教程进一步了解转换和坐标。
1.2.2 真实案例运用(Real case usage)
下面研究一个转换的实例,如下图所示。
与第二主 PCA 轴对齐的旋转直方图
这是一个简单的散点图,显示了一些高斯数据,有两个主轴。添加了一个与第一主成分(first principal component)轴正交的直方图,以显示在主要轴上的分布。此图像可能看起来很简单(一个散点图和一个定向直方图),但现实是完全不同的,绘制这样的图像远非显而易见。主要的困难是将直方图放在正确的位置、大小和方向,知道位置必须在数据坐标中设置,大小必须在图像标准化坐标中给出,方向必须以度为单位。更复杂的是,想用数据点来表示直方图条上方文本的高度。
你可以查看完整故事的来源,但让我们专注于主要难点,即添加一个旋转的浮动轴。让我们从一个简单的图像开始:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.transforms import Affine2D
import mpl_toolkits.axisartist.floating_axes as floating
fig = plt.figure(figsize=(8,8))
ax1 = plt.subplot(1,1,1, aspect=1,
xlim=[0,10], ylim=[0,10])
我们设想有一个浮动轴,它的中心在数据坐标中为(5,5),大小在数据坐标中为(5,3),方向为 -30 度:
center = np.array([5,5])
size = np.array([5,3])
orientation = -30
T = size/2*[(-1,-1), (+1,-1), (+1,+1), (-1,+1)]
rotation = Affine2D().rotate_deg(orientation)
P = center + rotation.transform(T)
在上面的代码中,定义了四个点来界定新轴的范围,并利用 matplotlib 仿射变换来做实际的旋转。此时,有四个点在数据坐标中描述轴的边界,需要将它们转换为图像标准化坐标,因为浮动轴需要图像标准化坐标。
DC_to_FC = ax1.transData.transform
FC_to_NFC = fig.transFigure.inverted().transform
DC_to_NFC = lambda x: FC_to_NFC(DC_to_FC(x))
还有一个额外难点,因为浮动轴的位置需要根据未旋转的边界框来定义:
xmin, ymin = DC_to_NFC((P[:,0].min(), P[:,1].min()))
xmax, ymax = DC_to_NFC((P[:,0].max(), P[:,1].max()))
我们现在有了添加新轴的所有信息:
transform = Affine2D().rotate_deg(orientation)
helper = floating.GridHelperCurveLinear(
transform, (0, size[0], 0, size[1]))
ax2 = floating.FloatingSubplot(
fig, 111, grid_helper=helper, zorder=0)
ax2.set_position((xmin, ymin, xmax-xmin, ymax-xmin))
fig.add_subplot(ax2)
结果如下图所示:
具有受控的位置、大小和旋转的浮动和旋转浮动轴
1.2.3 练习(Exercise)
- 在散点图中指定标记的大小时,该大小以点表示。尝试绘制散点图,其大小以数据点表示,如下图所示。
散点图,其标记大小以数据坐标而不是点表示
1.3 变换和变换(Scales & projections)(两种不同的高级变换)
除了仿射变换,matplotlib 还提供高级变换,允许在不修改数据的情况下彻底改变数据的表示。这些变换对应于数据预处理阶段,允许根据数据的性质调整渲染。正如 matplotlib 文档中所解释的那样,有两个主要的变换族:
- 单个维度,可分离变换,称为
Scales
, - 两个或多个维度,不可分离变换,称为
Projections
。
1.3.1 Scales
Scales
提供了一种数据与其在图像中沿给定维度的表示之间的映射机制。Matplotlib 提供了4个不同的 Scales
(线性 linear
,对数 log
,symlog
和 logit
),并修改了它们的图像,如调整 spine 的位置和标签(见下图)。注意,一个变换可以只用于 x 轴(set_xscale
),也可以只用于 y 轴(set_yscale
),或者两者都用。
默认(和隐式)scale 是线性(linear
)的,因此通常不需要指定任何内容。可以通过比较图形坐标中三个点之间的距离来检查一个 scale 是否是线性的,并检查它们在数据空间中的差异是否与图像空间中的差异相同,代码如下:
fig = plt.figure(figsize=(6,6))
ax = plt.subplot(1, 1, 1,
aspect=1, xlim=[0,100], ylim=[0,100])
P0, P1, P2, P3 = (0.1, 0.1), (1,1), (10,10), (100,100)
transform = ax.transData.transform
print( (transform(P1)-transform(P0))[0] )
# 4.185
print( (transform(P2)-transform(P1))[0] )
# 41.85
print( (transform(P1)-transform(P0))[0] )
# 418.5
对数变换(log
)是一个非线性刻度,其中每个间隔不是以相等的增量增加,而是以对数的底数的倍数增加(因此得名)。对数变化用于严格为正的值,因为对数对于负值和零值没有定义。如果我们应用前面的脚本来检查数据空间和图像形空间的差异,可以看到距离是一样的:
fig = plt.figure(figsize=(6,6))
ax = plt.subplot(1, 1, 1,
aspect=1, xlim=[0.1,100], ylim=[0.1,100])
ax.set_xscale("log")
P0, P1, P2, P3 = (0.1, 0.1), (1,1), (10,10), (100,100)
transform = ax.transData.transform
print( (transform(P1)-transform(P0))[0] )
# 155.0
print( (transform(P2)-transform(P1))[0] )
# 155.0
print( (transform(P1)-transform(P0))[0] )
# 155.0
如果数据有负值,你必须使用对称对数刻度(symlog
),它是线性和对数刻度的组合,即 0 附近的值使用线性刻度,0 附近以外的值使用对数刻度,可以在使用时指定线区域的范围。
logit
刻度用于
[
0
,
1
]
[0,1]
[0,1] 范围内的值,并在两端边界上使用对数刻度,在中间(约 0.5 处)使用准线性刻度。
也可以自定义刻度,示例代码和结果如下。注意,此时必须定义正向和反向转换函数。
def forward(x):
return x**(1/2)
def inverse(x):
return x**2
ax.set_xscale('function', functions=(forward, inverse))
最后,如果你需要一个具有复杂变换的自定义缩放,你可能需要编写一个适当的缩放对象,正如matplotlib文档中所解释的那样。
1.3.2 Projections
Projections
比 Scales
更复杂但也更强大。Projections
允许在把数据渲染为图形之前对其应用任意变换。只要知道如何将数据转换为二维(绘图空间)的东西,并需要定义正向和反向转换。
Matplotlib 只提供了一些标准 Projections
,但也提供了创建新的领域相关的 Projections
的所有机制,例如地图投影。原生 Projections
很少是因为,对于开发人员来说,实现和维护每一个特定领域的 Projections
都是耗时和困难的。他们选择将 Projections
限制在最通用的极坐标和三维上。
极坐标(polar)
在上一节说了 Scales
,其最简单直接的使用方法是在创建轴(axis)时指定:
ax = plt.subplot(1, 1, 1, projection='polar')
此轴现在配备了一个极坐标变换。说明任何绘图命令都经过预处理,以便(自动)对数据进行正向变换。此时,正向变换必须指定如何从极坐标
(
ρ
,
θ
)
(\rho,\theta)
(ρ,θ) 到笛卡尔坐标
(
x
,
y
)
=
(
ρ
cos
θ
,
ρ
sin
θ
)
(x,y)=(\rho\cos\theta,\rho\sin\theta)
(x,y)=(ρcosθ,ρsinθ)。在声明极轴时,可以像以前一样指定极轴的限制范围,但也有一些专门的设置,如 set_thetamin
,set_thetamax
,set_rmin
,set_rmax
和更专门的 set_rorigin
。这可以很好地控制实际显示的内容,如下图所示。
若现在绘制一些图(例如折线图、散点图、条形图),会发现除了一些元素外,所有东西都发生了变化。更确切地说,标记的形状不会改变(圆盘标记在视觉上仍将是圆盘),文本不会改变(使其保持可读),线条的宽度保持不变。看一个更详尽的图,以更准确地了解它的含义。
上图主要使用 fill_between
命令绘制了一个简单的信号。同心灰/白圆环使用 fill_between
命令在两个不同的
ρ
\rho
ρ 值之间制作,而直方图使用各种不同的
ρ
\rho
ρ 值制作。仔细观察
ρ
\rho
ρ 轴,刻度从 100 到 900 不等,刻度值具有相同的垂直大小。这确实是作者出于纯粹的美学原因故意引入的异常现象。若使用绘图命令指定了这些刻度,每个刻度的长度将对应于角度差(垂直尺寸),并随着远离中心而变得越来越高。为了得到规则的刻度,必须使用反向变换进行一些计算(记住,Projection 是正向和反向变换)。细节见作者代码。反向变换的实际作用是将鼠标坐标(在笛卡尔二维坐标系中)链接回数据。
相反,有时可能要对文本和标记进行变换,如下图。
在这个例子中,标记和文本都进行了手动变换。对于标记,技巧是使用椭圆,它被近似为一系列小线段,每个线段都被变换。在相应的代码中,只指定了中心,伪标记的大小和预处理阶段负责将极坐标变换应用于构成标记(椭圆)的每个单独部分,从而得到一个略微弯曲的椭圆。对于文本来说,处理过程也是一样的,但是要稍微复杂一点,因为需要首先将文本转换为可以变换的路径(将在下一章中详细介绍)。
3D
Matplotlib 提供的第二个变换是3D变换,即从 3D 笛卡尔空间到 2D 笛卡尔空间的变换。要开始使用 3D 变换,需要使用 matplotlib 通常附带的 Axis3D 工具包:
from mpl_toolkits.mplot3d import Axes3D
ax = plt.subplot(1, 1, 1, projection='3d')
有了这个 3D 轴,可以使用常规的绘图命令,但有一个很大的区别:现在需要提供 3 个坐标 (x,y,z),而以前只提供了两个 (x,y),如下图
注意,此图与默认的 3D 轴有很大的不同。在这里,作者调整了能想到的每一个设置,试图改进默认外观,并展示如何更改。查看作者相应的代码,并尝试修改一些设置看看实际效果。3D 轴的 API 在 matplotlib 网站上有很好的文档,作者不会解释每一个命令。
对于其他类型的变换,要根据要使用的变换类型安装第三方库:
- Cartopy 是专为地理空间数据处理而设计的 Python 库,用于生成地图和其他地理空间数据分析。Cartopy 利用了强大的 PROJ.4、NumPy 和 Shapely 库,并包含一个基于 Matplotlib 构建用于创建出版级质量的编程接口。
- GeoPandas 是一个开源项目,使得在 Python 中处理地理空间数据更容易。GeoPandas 扩展了 pandas 使用的数据类型,允许对几何(geometric)类型进行空间操作。几何运算由 Shapely 库执行。Geopandas 进一步依赖 fiona 进行文件访问,并依赖 descartes 和 matplotlib 进行绘图。
- Python-terary 是一个与 matplotlib 一起使用的绘图库,用于在投影到二维平面上的二维单纯形中绘制三元图。该库提供用于绘制投影线、曲线(轨迹)、散点图和热图的功能。下面有几个例子和一个简短的教程。
- pySmithPlot 是一个 matplotlib 扩展,它提供了一个变换类,用于使用 Python 创建高质量的 Smith Chart。生成的绘图与 matplotlib 的风格无缝融合,并支持几乎所有的自定义选项。
- Matplotlib-3D 是一个实验项目,试图为 Matplotlib 提供更好、更通用的 3D 轴。
若仍对现有的变换不满意,则只能创建自己的变换,但这是一个相当高级的操作,尽管 matplotlib 文档提供了一些示例。
1.3.3 练习(Exercise)
- 考虑函数 f ( x ) = 1 0 x f(x)=10^x f(x)=10x, f ( x ) = x f(x)=x f(x)=x 和 f ( x ) = log 10 ( x ) f(x)=\log_{10}(x) f(x)=log10(x),尝试复现下图。
- 绘制一个麦克风极线图案(microphone polar patterns)(全向(omnidirectional)、亚心形(subcardioid)、心形(cardioid)、超心形(supercardioid)、双向形(bidirectional)和霰弹枪形(shotgun))的图形,如下图。前五种模式是简单的函数,其中半径随角度变化,而最后一种模式可能需要一些工作。
1.4 排版元素(Elements of typography)
如果在系统上安装了一个新字体,要重建字体列表缓存,否则 Matplotlib 会忽略新安装的字体:
import matplotlib.font_manager
matplotlib.font_manager._rebuild()
1.4.1 字体堆栈(Font stacks)
Matplotlib 字体堆栈使用四种不同的字体族定义,即
- 无衬线(sans)
- 衬线(serif)
- 等宽(monospace)
- 手写体(cursive)
要检查实际使用的字体,可以用代码:
from matplotlib.font_manager import findfont, FontProperties
for family in ["serif", "sans", "monospace", "cursive"]:
font = findfont(FontProperties(family=family))
print(family, ":" , os.path.basename(font))
通过修改 rc 文件或样式表(stylesheet)(见第 2.2 节:掌握默认值),可以将一个字体堆栈用作默认字体,但也可以为任何文本对象使用特定的字体,如刻度标签、图例、图形标题等。但是,为了一致性,最好对整个图像使用相同的字体系列(衬线、无衬线和等宽)。
1.4.2 渲染数学(Rendering mathematics)
数学文本的情况稍微复杂一些,因为它需要几种具有所有必要数学符号的不同字体,而这样的字体并不多。Matplotlib提供了五个不同的系列,即
- DejaVu(无衬线和衬线)
- Styx(无衬线和衬线)
- computer modern:
Matplotlib 有自己的 TeX 解析器和布局引擎(TeX parser and layout engine),尽管它存在一些缺陷,但功能非常强大。
可以通过设置 usetex
变量来使用真正的 TeX 引擎:
import matplotlib as mpl
plt.rcParams.update({"text.usetex": True})
1.4.3 关于尺寸的说明(A note about size)
字体大小是以英寸为单位指定的,因此表观大小通过每英寸点数(dpi)参数直接与图像的分辨率(而不是尺寸)相关联。因此,可以定义一个非常大或非常小的图像,大小为 10 的字体在屏幕上具有相同的视觉效果。
- 练习:使用不同的字体、粗细和大小,尝试复现下图。
1.4.4 可读性(Legibility)
对于传统文档,文本通常在白色背景下以黑色呈现,以最大限度地提高可读性。科学可视化的情况有点不同,因为在某些情况下,你无法控制背景颜色,因为它是你结果的一部分。
如果要在图像上添加文本,如下图,最好的选项显示在最后一行,用细边框勾勒字体,使得文本清晰、美观,不会隐藏太多数据。
- 练习:尽量精确地复现下图,其使用 Pacifico 字体族。颜色来自岩浆颜色图(岩浆颜色图)。确保使用不同的轮廓宽度,以获得每种颜色之间的细黑线。
Matplotlib 提供了两种类型的文本对象。
- 第一种也是最常用的是用于标签、标题或注释的常规文本。它不能被大量变换,大多数时候,即使可以自由旋转,文本也会按照单一方向(例如水平或垂直)呈现。
- 另一种类型的文本对象,即 TextPath。用法很简单:
from matplotlib.textpath import TextPath
from matplotlib.patches import PathPatch
path = TextPath((0,0), "ABC", size=12)
结果是一个可以插入到图像中的路径对象(path object)
patch = PathPatch(path)
ax.add_artist(patch)
这种路径对象真正有趣的是,它现在可以在构成字形的单个顶点的级别进行变换,如下图。(字可以随着等高线弯曲)
在上图中,作者用跟随路径的文本路径对象替换了规则的轮廓标签。这需要一些计算,但实际上并没有那么多。如果轮廓线太小或有尖锐的转折,就会使文本不可读。
文本路径的另一个有趣的用法是 3D 投影的情况,如下图,利用 3D text API 来定位和转换轴标签和轴标题。注意,只要图形方向正确,这种投影就很好。如果要旋转,文本可能很难阅读,这就是为什么 3D 投影的默认设置是文本总是面对相机,以确保可读性。
- 练习:尝试复现下图。对依赖于 Y 水平的 X 顶点的简单压缩应该是有效的。路径的顶点可以通过
path.vertices
进行访问。
1.5 颜色入门(A primer on colors)
为了在计算机上表示颜色,人们(大多数时候)使用颜色模型(color model)(如何表示颜色)和颜色空间(color space)(可以表示什么颜色)的概念。存在多种颜色模型(RGB、HSV、HLS、CMYK、CIEXYZ、CIELAB等)和多种颜色空间(Adobe RGB、sRGB、Colormatch RGB等)。
计算机的标准(自1996年以来)是 sRGB 颜色空间,其中 s 代表标准。该颜色空间使用基于 RGB 模型的加色模型。这意味着,为了获得给定的颜色,需要混合不同量的红光、绿光和蓝光。当这些量全部为零时得到黑色,当这些量都处于满强度时得到白色(D65 白点,参见 CIE 1931 xy chromaticity space)。
因此,当在 matplotlib 中指定一种颜色(例如“#123456”)时,需要意识到这种颜色是隐式编码在 sRGB 颜色模型和空间中的。
构造渐变色的三种方式
- 使用朴素的方法在两种颜色之间产生渐变,会得到错误的感知结果,因为sRGB 模型不是线性的。如下图,使用 sRGB 朴素方法绘制了渐变(每个渐变的第一行),可以看出效果不令人满意。
- 构建渐变的更好方法是首先将颜色转换到线性 RGB 空间,应用渐变,然后将其转换回 sRGB 颜色模型。如下图每个渐变的第二行,可以看出渐变更平滑。
- 第三种(也是更更好的)解决方案是使用 CIE Lab 模型,该模型根据人类感知量身定制,并提供感知均匀的空间。操作起来有点复杂,需要 scikit-image 或 colour 等额外的库来在不同模型和空间之间进行转换,但结果是值得的。
另一个流行的模型是 HSV 模型,代表色调(Hue)、饱和度(Saturation)和明度(Value)(见下图)。它提供了一种替代颜色模型来访问与 sRGB 系统相同的颜色空间。Matplotlib 提供了转换 HSV 模型的方法(见 colors 模块)。
1.5.1 选择颜色(Choosing colors)
当一次绘制多个图时,这些图使用了几种不同的颜色。这些颜色是从所谓的颜色循环(color cycle)中挑选出来的:
import matplotlib.pyplot as plt
print(plt.rcParams['axes.prop_cycle'].by_key()['color'])
# ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
# '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
以下这些颜色来源于 Tableau 软件中的 tab10 颜色图:
import matplotlib.colors as colors
cmap = plt.get_cmap("tab10")
print([colors.to_hex(cmap(i)) for i in range(10)])
# ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
# '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
这些颜色被设计得足够不同,以减轻视觉上的差异感,同时不会对眼睛造成太大的伤害(例如,与饱和的纯蓝色、绿色或红色相比)。
如果需要更多的颜色,首先要问自己是否真的需要更多的色彩,再考虑使用精心设计的调色板。下面两图是开放(open)调色板和材质(material)调色板的情况。
open colors palette
material color palette
例如,下图使用两个颜色堆栈(材质调色板中的蓝灰色(blue grey)和黄色(yellow))来突出显示感兴趣的区域。
调色板的另一种用法是使用颜色堆栈来识别不同的组,同时允许每个组内部的变化。执行此操作时,需要在所有后续图形中保留相同的颜色语义,如下图。
使用颜色堆栈识别具有内部变化的组。
颜色的另一种流行用法是显示和其标准偏差(standard deviation,SD)或标准误差(standard error,SE)相关的一些图。要做到这一点,有两种不同的方法。要么使用前面定义的开放和材质调色板,要么使用 alpha
关键字使用透明度。让我们比较一下结果。
如下图左侧所示,使用透明度会使两个图以某种方式混合在一起。这可能是一个有用的效果,因为它允许显示共享区域中发生的事情。当使用不透明颜色时,情况并非如此。因此必须决定谁覆盖谁(使用 zorder
),如下图右侧。
注意,透明度的使用非常特殊,因为视觉结果在脚本中没有明确指定。这实际上取决于图像的实际渲染和 matplotlib 组合不同元素的方式。考虑一个散点图(正态分布),其中每个点都是透明的(10%),如下图。从左图,可以看到中心有一个感知上较暗的区域,这是在中心区域将几个小光盘叠加在一起的直接结果。若要量化这种感知结果,需要使用一种技巧,在数组(array)中渲染散点图,就可以将结果视为图像,即为中间的图。并且由此可得感知密度图,如右图。
1.5.2 选择颜色图(Choosing colormaps)
颜色映射对应于将值映射到颜色。有不同类型的颜色图(顺序的(sequential)、发散的(diverging)、循环的(cyclic)、定性的(qualitative)或这些都不是)对应于不同的用例。使用与数据相对应的正确的类型或颜色图非常重要。
要选择颜色图,可以从回答下图的问题开始,然后从 matplotlib 网站上选择相应的颜色图(colormap)。
下图显示了一些与顺序(Sequential)颜色图相关的选项。此时,一个选择标准可能是图像代表一个人,我们可能更喜欢接近肤色的颜色图。
发散(diverging)颜色图需要特别注意,因为它们实际上是由两个具有特殊中心值的渐变组成的。默认情况下,该中心值在标准化线性映射中映射为 0.5,只要数据的绝对最小值和最大值相同,那么效果就很好。
考虑下图所示的情况。有一个负值的小域和一个正值的大域。理想情况下,希望负值映射为蓝色,正值映射为黄色。
如果要在没有任何预防措施的情况下使用发散(diverging)颜色图,就不能保证会得到想要的结果。因此要告诉 matplotlib 中心值是什么,为此,我们需要使用双斜率范数(Two Slope norm) 而不是线性范数(Linear norm):
import matplotlib.pyplot as plt
import matplotlib.colors as colors
cmap = plt.get_cmap("Spectral")
norm = mpl.colors.Normalize(vmin=-3, vmax=10)
print(norm(0))
# 0.23076923076923078
print(cmap(norm(0)))
# (0.968, 0.507, 0.300, 1.0)
norm = mpl.colors.TwoSlopeNorm(vmin=-3, vcenter=0, vmax=10)
print(norm(0))
# 0.5
cmap = plt.get_cmap("Spectral")
print(cmap(norm(0)))
# (0.998, 0.999, 0.746, 1.0)
1.5.3 练习(Exercise)
- 复现下图。诀窍是将每条线分割成小段,这样它们就可以各自拥有自己的颜色,因为使用常规绘图无法做到这一点。但是,出于性能原因,需要使用 LineCollection。可以从以下代码开始:
X = np.linspace(-5*np.pi, +5*np.pi, 2500)
for d in np.linspace(0,1,15):
dx, dy = 1 + d/3, d/2 + (1-np.abs(X)/X.max())**2
Y = dy * np.sin(dx*X) + 0.1*np.cos(3+5*X)
- 这个练习有点棘手,需要使用 PolyCollection。棘手的部分是根据分支和截面的数量,以通用的方式定义每个多边形。它主要是三角函数。建议从只绘制主线开始,然后创建小 patch。颜色部分应该很容易,因为它只取决于角度,因此可以使用 HSV 编码。