跳到正文

Matplotlib 样式定制:rcParams 与出版级图表

图上的元素分两类:一类由数据决定(点的位置、柱子的高度、颜色代表的分组),另一类和数据无关(字体多大、线多粗、有没有网格、边框画不画、图例摆在哪)。样式定制只处理第二类。改样式永远不会改变图传达的事实,只会改变它印出来够不够专业。

Matplotlib 的样式分两层:全局的 rcParams(一次设置,之后每张图都生效)和局部的对象方法(只作用于当前的 axfig)。先想清楚某个设置是「整套图统一」还是「就这一张特殊」,再决定写在哪一层,能省掉大量重复代码。如果对 fig/ax 的分工还不熟,先看 /python/visualization/matplotlib-basics/

plt.rcParams 是一个类字典对象,存着 Matplotlib 全部默认值。查当前值直接按 key 取:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
for k in ["figure.figsize", "figure.dpi", "savefig.dpi", "font.size",
"axes.titlesize", "axes.labelsize", "lines.linewidth",
"axes.linewidth", "xtick.labelsize", "legend.fontsize"]:
print(f"{k:20s} {plt.rcParams[k]}")
figure.figsize [6.4, 4.8]
figure.dpi 100.0
savefig.dpi figure
font.size 10.0
axes.titlesize large
axes.labelsize medium
lines.linewidth 1.5
axes.linewidth 0.8
xtick.labelsize medium
legend.fontsize medium

几个要点。figure.figsize 的单位是英寸,[6.4, 4.8] 是屏幕友好的尺寸,不是投稿尺寸。font.size 是字号基准,axes.titlesizeaxes.labelsizextick.labelsizelegend.fontsize 这些相对值(largemedium)都按它换算,所以改 font.size 一处,全套文字跟着变。savefig.dpi 的值是字符串 figure,意思是「保存时用 figure.dpi」,不是数字。

修改用 update() 一次写多条。rcParams 的修改只对当前进程有效,脚本退出就没了;要跨文件复用,把它包成一个函数放进单独模块:

plot_style.py
import matplotlib.pyplot as plt
def set_pub_style(base_size=9, width_in=3.5, height_in=2.6):
plt.rcParams.update({
"figure.figsize": (width_in, height_in),
"figure.dpi": 150,
"savefig.dpi": 300,
"font.size": base_size,
"axes.titlesize": base_size + 1,
"axes.labelsize": base_size,
"xtick.labelsize": base_size - 1,
"ytick.labelsize": base_size - 1,
"legend.fontsize": base_size - 1,
"axes.linewidth": 0.8,
"axes.spines.top": False,
"axes.spines.right": False,
"lines.linewidth": 1.2,
"lines.markersize": 4,
"xtick.direction": "out",
"ytick.direction": "out",
"legend.frameon": False,
"font.sans-serif": ["DejaVu Sans", "Droid Sans Fallback"],
"axes.unicode_minus": False,
})

有了这个函数,每个分析脚本开头两行就统一了全项目风格:

from plot_style import set_pub_style
set_pub_style()
print("font.size ->", plt.rcParams["font.size"])
print("figsize ->", plt.rcParams["figure.figsize"])
print("spines.top ->", plt.rcParams["axes.spines.top"])
font.size -> 9.0
figsize -> [3.5, 2.6]
spines.top -> False

字号基准用参数传进来,图幅也参数化,是因为文字的实际大小取决于图幅。同一个 9 pt,在 3.5 英寸宽的图里读起来正常,放到 7 英寸宽的图里就偏小。所以要改图幅时,字号得跟着调——与其到处改,不如让函数同时管这两件事。

Matplotlib 自带一批样式表,每个就是一组调好的 rcParams。先看有哪些:

print("total:", len(plt.style.available))
print([s for s in plt.style.available if s.startswith("seaborn")])
total: 28
['seaborn-v0_8', 'seaborn-v0_8-bright', 'seaborn-v0_8-colorblind', 'seaborn-v0_8-dark', 'seaborn-v0_8-dark-palette', 'seaborn-v0_8-darkgrid', 'seaborn-v0_8-deep', 'seaborn-v0_8-muted', 'seaborn-v0_8-notebook', 'seaborn-v0_8-paper', 'seaborn-v0_8-pastel', 'seaborn-v0_8-poster', 'seaborn-v0_8-talk', 'seaborn-v0_8-ticks', 'seaborn-v0_8-white', 'seaborn-v0_8-whitegrid']

样式表有两种用法。全局切换用 plt.style.use(),只影响当前代码块(脚本后续部分)用 with 语句:

print("before:", plt.rcParams["axes.grid"], plt.rcParams["font.size"])
with plt.style.context("seaborn-v0_8-whitegrid"):
print("inside:", plt.rcParams["axes.grid"], plt.rcParams["font.size"])
print("after:", plt.rcParams["axes.grid"], plt.rcParams["font.size"])
before: False 10.0
inside: True 10.0
after: False 10.0

with 块退出后参数自动还原,适合「这一张图想试试白色网格,别的图保持原样」的场景。plt.style.use() 则会一直留着,之后所有图都按它画。

样式表和 rcParams 的关系是后者覆盖前者:先 plt.style.use("seaborn-v0_8-paper") 打底,再 plt.rcParams.update({...}) 覆盖其中几项,是最常见的组合方式。不同样式表在这些参数上的差别:

样式表 figure.figsize font.size axes.linewidth axes.grid lines.linewidth
default [6.4, 4.8] 10.0 0.8 False 1.5
classic [8.0, 6.0] 12.0 1.0 False 1.0
seaborn-v0_8-paper [6.4, 4.4] 10.0 0.8 False 1.4
seaborn-v0_8-whitegrid [6.4, 4.8] 10.0 1.0 True 1.5
ggplot [6.4, 4.8] 10.0 1.0 True 1.5
bmh [6.4, 4.8] 10.0 0.8 True 2.0

差距集中在三点:图幅、网格开关、线宽。classic 是 Matplotlib 1.x 的老样式,现在主要用来复现早期论文里的图;seaborn-v0_8-paper 的图幅 [6.4, 4.4] 是为期刊单栏比例调过的;ggplot 带灰底白网格,投影仪上看清楚,印在纸上费墨。

选择依据是用途:投期刊选 seaborn-v0_8-paperseaborn-v0_8-whitegrid,组会幻灯片可以用 ggplotbmhplt.rcdefaults() 退回初始状态,调试样式冲突时先执行一次,排除历史设置的干扰。

全局设定之后,总有个别图需要特殊处理。这时不要去改 rcParams——那会影响后续所有图。直接在对象上设置:

fig, ax = plt.subplots(figsize=(3.5, 2.5))
ax.plot([1, 2, 3], [1, 4, 9])
ax.tick_params(labelsize=7, direction="in", length=2)
ax.set_title("local override")
global font.size still: 10.0
ax tick labelsize: 7.0

global font.size 仍是 10.0,只有这张图的刻度标签变成 7 pt。这是 ax 方法相对 rcParams 的价值:作用范围明确,改动不会外溢。

方法名有规律:ax.set_xxx() 设置属性(set_xlimset_xlabelset_title),ax.xxx() 往图里加东西(plotscatterlegend)。ax.tick_params() 是前者里最常用的一个,管刻度线的方向、长度、粗细以及刻度标签字号。

需要一整段临时样式时,plt.style.context() 可以和对象方法叠加:样式表管大局,ax 方法管细节,两层互不冲突。

中文显示成方框,原因是当前字体里没有对应的字形(glyph)。默认的 DejaVu Sans 覆盖拉丁字母和符号,不含汉字。与其看方框猜,不如直接问字体库有没有这个字形:

from matplotlib import font_manager, ft2font
def has_glyph(family, ch):
path = font_manager.findfont(family)
return ft2font.FT2Font(path).get_char_index(ord(ch)) != 0
for fam in ["DejaVu Sans", "Droid Sans Fallback"]:
print(fam, {c: has_glyph(fam, c) for c in "维-("})
DejaVu Sans {'维': False, '-': True, '(': True}
Droid Sans Fallback {'维': True, '-': False, '(': False}

get_char_index() 返回 0 表示该字体没有这个字形。结果很说明问题:DejaVu Sans 有连字符和括号、没有「维」;Droid Sans Fallback 有「维」、没有连字符和括号。这两个字体各自都不完整——在只装了一个 CJK 字体的机器上,把 font.sans-serif 设成那一个字体,中文正常了,负号和括号反而会变成方框。 正确做法是给一个列表,让 Matplotlib 按顺序回退:

plt.rcParams["font.sans-serif"] = ["DejaVu Sans", "Droid Sans Fallback"]
plt.rcParams["axes.unicode_minus"] = False

第二行是配套的。Matplotlib 的默认数学字体用 U+2212 作减号,多数中文字体不含这个码位,负号会变成方框。关掉 axes.unicode_minus 后改用 ASCII 连字符,就正常了。

先查系统里有哪些中文字体:

终端窗口
$ fc-list :lang=zh
/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf: Droid Sans Fallback:style=Regular

输出格式是「文件路径: 字体家族名:style=样式」,冒号后面那段 Droid Sans Fallback 才是要写进 rcParams 的名字。三个平台的常见值:Linux 是 Noto Sans CJK SCWenQuanYi Zen HeiDroid Sans Fallback,Windows 是 SimHeiMicrosoft YaHei,macOS 是 PingFang SCHeiti SC

名字必须和系统注册的完全一致,写错了 Matplotlib 不报错,只是静默回退到默认字体,中文照样是方框。 所以配完之后一定要看一眼图,不能只看代码有没有抛异常。另外 font.familyfont.sans-serif 分工不同:前者选字体族(sans-serifserifmonospace),后者是 sans-serif 族内部的具体字体列表,两个都要写对。

离散分组和连续变量分开处理。

离散分组(处理组、物种、剂量)优先选对色觉缺陷友好的定性配色。Okabe-Ito 那一套是最常用的:

from cycler import cycler
plt.rcParams["axes.prop_cycle"] = cycler(
color=["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#F0E442", "#56B4E9"]
)
print("custom cycle:", [c["color"] for c in plt.rcParams["axes.prop_cycle"]])
custom cycle: ['#0072B2', '#D55E00', '#009E73', '#CC79A7', '#F0E442', '#56B4E9']

设好 axes.prop_cycle 之后,不指定颜色的 plot()scatter() 会按这个顺序自动取色。也可以直接用内置的 tab10seaborn-v0_8-colorblind 样式表。需要固定某个分组对应某个颜色时用手动映射(ax.scatter(..., color=colors[name])),别依赖取色顺序。

连续变量用感知均匀(perceptually uniform)的渐变:

print("perceptually uniform:", [c for c in plt.colormaps() if c in ("viridis","plasma","inferno","magma","cividis")])
print("total colormaps:", len(plt.colormaps()))
perceptually uniform: ['magma', 'inferno', 'plasma', 'viridis', 'cividis']
total colormaps: 182

内置配色共 182 个,其中上面五个是感知均匀的——从暗到亮的过渡在视觉上是等间距的,转成灰度打印后明暗依然单调。cividis 是其中对色觉缺陷优化过的版本,红绿色盲也能分辨。单调递增的量(浓度、强度)用 viridiscividis;有明确中点的量(相对均值的偏差、正负相关)用发散型 RdBu_rcoolwarm,让颜色在中点两侧对称展开。

不要用 jetrainbow 表示连续量。 人眼对亮度差异的分辨力远高于色相差异,彩虹配色不是单调的明暗变化,而且会在图上造出根本不存在的「边界」——数据里没有突变,读图的人却看到一条分界线。

投稿图最常见的样子是「去掉上边框和右边框,只留左、下两条轴线」。spines 对象对应四条边框:

fig, ax = plt.subplots(figsize=(3.5, 2.5))
ax.plot([1, 2, 3], [1, 4, 9])
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.grid(axis="y", linestyle=":", linewidth=0.6, alpha=0.7)
spines visible: {'left': True, 'right': False, 'bottom': True, 'top': False}

ax.spines 是个字典,key 是 leftrightbottomtop,除 set_visible(False) 外还可以 set_linewidth()set_color()set_position()。写进全局配置就是 "axes.spines.top": False,一次去掉所有图的上边框。

网格要克制。ax.grid(axis="y", ...) 只画水平网格线(axis="x" 纵向,axis="both" 两者都画),虚线、细线、低透明度三者叠加才不至于喧宾夺主。ax.set_axisbelow(True) 让网格线画在数据下面,否则网格会盖住柱子和点。默认的次网格线(axes.grid.which)通常不需要。

图例用 ax.legend() 加,位置靠 loc 参数("upper left""lower right" 等),不想要边框设 frameon=False。图例只认带 label= 的元素,散点用 label= 写在 ax.scatter() 里,分多次画的可以用 ax.legend(handles=[...], labels=[...]) 手动传。

fig.savefig("fig1.pdf")
fig.savefig("fig1.png", dpi=300)
fig.savefig("fig1.svg")

三个参数的取值直接决定成品。figsize最终排版宽度设,不要按屏幕感觉设:期刊单栏宽约 3.5 英寸(8.9 cm),通栏约 7 英寸(17.8 cm)。按 3.5 英寸画、9 pt 字号,排版后就是 9 pt;按 10 英寸画再被压到 3.5 英寸,所有文字跟着缩到三分之一,印出来只有 3 pt 左右。

dpi 只影响位图,且与文件大小近似成正比。一张 3.5×2.5 英寸的散点图:

dpi 像素尺寸 PNG 体积
72 252 × 180 7.0 KB
150 525 × 375 16.7 KB
300 1050 × 750 36.8 KB
600 2100 × 1500 80.7 KB

像素尺寸等于 figsize × dpi,所以调 dpi 不改变图的物理尺寸和文字相对大小,只改变清晰度。期刊对线条图一般要求 600 dpi 以上,含半色调的图 300 dpi 起步。

格式选择按图的类型。折线图、散点图、柱状图属于线条图(line art),存矢量格式 PDF 或 SVG:放大不糊,而且是按路径存的,同一张图 PDF 的体积明显小于高 dpi 的 PNG。

终端窗口
$ ls -lh fig1.pdf fig1.png
-rw-rw-r-- 1 user user 15K Sep 23 07:19 fig1.pdf
-rw-rw-r-- 1 user user 81K Sep 23 07:19 fig1.png

PDF 还有一个好处是编辑部能拿去继续排版,文字可检索、可编辑。照片和热图这类连续色调的图才必须用位图。存 PDF 之前记得确认字体嵌入了,否则换台机器打开可能掉字体。

bbox_inches="tight" 会自动裁掉图例、标签之外的空白。它的代价是不同图的最终尺寸会随内容变化,多张图需要严格对齐拼版时就不要用,改用 fig.tight_layout()fig.subplots_adjust() 手动控制边距。两个一起用不会报错,但边距会被 tight 再裁一次,结果不好预测——选一个就行。

保存和显示的顺序不能反:先存再显。plt.show() 会把画布交给后端渲染并阻塞,画布此后不再是完整状态,再保存拿到的是空白图或直接报错。

三条可以量化的标准:

  1. 去掉颜色后信息还在不在。 转成灰度看一眼,分组差异是否仍靠形状、线型、位置传递。
  2. 按最终尺寸打印,最小的字还认不认得。 刻度标签低于 7 pt 就该调,字号是绝对值,必须按成品尺寸检查。
  3. 坐标轴有没有写单位。 Petal length (cm) 而不是 Petal length

同一批图的字号、线宽应当来自同一个 set_pub_style() 调用,不要一张一张手调。

改了 rcParams 却没生效。 多半是设置写在 fig 创建之后。rcParams 只影响之后创建的对象,已建好的 figax 不会追溯更新,样式设置要放在脚本最前面。

图缩小后文字挤在一起。 字号是绝对单位,figsize 调小时文字不跟着缩。不要用 font.size = 6 硬压,先判断内容是不是太挤,该拆子图,见 /python/visualization/subplots/

在 Jupyter 里改了样式,其他 cell 的图也跟着变。 rcParams.update() 改的是进程级状态,所有 cell 共享同一个内核。临时试验用 with plt.style.context(...)

颜色写死成 "r""b" 超过五组就分不清,红绿组合在色觉缺陷下几乎无法区分。用 axes.prop_cycle 统一取色,或交给 seaborn 的 hue 参数,见 /python/visualization/seaborn/

配了中文字体后负号变方框。 中文字体通常不含 U+2212,把 axes.unicode_minus 设为 False

R 语言的主题系统解决的是同一类问题,theme() 对应这里的 rcParamselement_text()element_line() 对应具体参数项,对照 /r/visualization/themes/ 看两套「全局预设」思路的差别。图能画得符合投稿要求之后,下一步是用数据验证结论:/python/modeling/descriptive-stats/ 讲描述性统计与假设检验。