Matplotlib 科研绘图教程

环境:Windows 11 + Miniconda,Python 3.14.4,Matplotlib 3.10.9,VS Code


1. 简介与定位

Matplotlib 是 Python 生态里最底层、最通用的 2D 绘图库。对Python科研绘图而言,它几乎是所有科研出图(能带、DOS、吸附能、催化性能对比、势能面)的最终兜底工具。

需要先摆正一个观念:

  • Matplotlib 是"命令式 + 面向对象"的绘图库,不是"声明式"的(不像 Excel / Origin 点几下)。
    你写的每一句代码,本质都是在操纵一个 Figure 对象和它内部的若干 Axes 对象。
  • 它在数值处理上依赖 NumPy,所以本文所有示例都默认:
import numpy as np
import matplotlib.pyplot as plt
  • Matplotlib 的 3.x 系列 API 非常稳定,本文所有函数名 / 参数都以 3.10.9 为准,
    不会出现你复制粘贴却报 AttributeError 的"伪代码"。

什么时候用 Matplotlib 而不是别的?

  • 需要精确控制坐标轴、刻度、图例、字体、dpi 的投稿级图片 → Matplotlib(或它的高层封装)。
  • 快速探索性统计(箱线图、小提琴图、热力图)→ Seaborn(第 12 节)。
  • 交互式三维结构 / 晶体 → 请用 ASE / VESTA / OVITO,不要用 Matplotlib 硬画 3D。

2. 安装与版本检查

Miniconda 环境下推荐单独建一个绘图环境,避免污染你的 DFT / ML 环境:

conda create -n plot python=3.12 -y
conda activate plot
conda install -c conda-forge matplotlib numpy pandas scipy -y

版本检查(写脚本前先确认,避免"文档里的新参数在你环境里没有"):

import matplotlib
import numpy as np

print("Matplotlib:", matplotlib.__version__)   # 期望 3.10.x
print("NumPy:", np.__version__)
print("Backend:", matplotlib.get_backend())    # VS Code 交互式通常是 TkAgg / QtAgg / module://...

小贴士:matplotlib.get_backend() 显示的是当前渲染后端。VS Code 里启用
"Interactive Window / Jupyter" 会切换到内联后端;纯 .py 脚本 plt.show()
则会弹出一个独立窗口。若偏好 .py 脚本,那么最终保存图片请用 fig.savefig()
而不是截图,保证分辨率与矢量格式(见第 10 节)。


3. pyplot 快速绘图 vs 面向对象 API

3.1 两种写法长什么样

pyplot(函数式)写法 —— 隐式管理"当前图"和"当前坐标轴",适合快速试探:

import matplotlib.pyplot as plt

plt.plot([1, 2, 3], [1, 4, 9])
plt.xlabel("x")
plt.ylabel("y")
plt.title("pyplot 风格")
plt.show()

面向对象(Figure/Axes)写法 —— 显式拿到 fig 和 ax,再对 ax 操作:

import matplotlib.pyplot as plt

fig, ax = plt.subplots()   # fig 是整个画布,ax 是一套坐标轴
ax.plot([1, 2, 3], [1, 4, 9])
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title("面向对象风格")
fig.savefig("oo_style.png", dpi=300)

3.2 为什么科研出图强烈推荐后者

  1. 可读性 / 可维护性:ax.set_xlabel(...) 明确知道改的是哪个子图;plt.xlabel(...)
    在多个子图时经常"改错对象"。
  2. 多子图必须 OO:plt.subplots(2, 2) 返回一个 Axes 数组,你必然要拿到每个
    ax 去画,pyplot 的"隐式当前图"在这里只会添乱。
  3. 复用性:把"画一张催化性能对比图"封装成 def plot_faradaic(df, ax),只接收
    ax 参数,想放哪个子图就传哪个,这是脚本化出图的核心套路。
  4. 避免状态污染:pyplot 会保留全局状态(当前 figure/axes),循环出图时容易串图。

结论:本文从第 4 节起全部使用 OO 风格。plt 只用来干三件事:plt.subplots、
plt.show、以及 plt.rcParams 全局设置。


4. Figure / Axes 概念

  • Figure:整张画布(可以理解成"一页纸"),可以有多个 Axes。
  • Axes:画布上的一个"坐标系区域",是真正画画的地方(线、点、柱、色块都画在这里)。
    注意 Axes ≠ axis;axis 是坐标轴(x 轴 / y 轴),Axes 是包含坐标轴的整块绘图区。
  • 一个 Axes 通常包含两个 Axis(x、y),还可以有 twinx 得到的第二个 y Axis。

4.1 单图

fig, ax = plt.subplots(figsize=(6, 4), dpi=150)
ax.plot(np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100)))
fig.tight_layout()

4.2 多子图 subplots

fig, axes = plt.subplots(2, 2, figsize=(8, 6), sharex=True, sharey=True)
# axes 是一个 shape=(2,2) 的 ndarray,可用 axes[0,1] 索引
axes = axes.flatten()               # 拍平成一维,遍历方便
for i, ax in enumerate(axes):
    x = np.linspace(0, 4 * np.pi, 200)
    ax.plot(x, np.sin(x + i * np.pi / 4))
    ax.set_title(f"子图 {i + 1}")
fig.suptitle("subplots 示例", fontsize=14)
fig.tight_layout()

4.3 subplot_mosaic(推荐,语义化布局)

用字符串描述布局,一行一个子图区,同一字符表示跨行 / 跨列合并。这是画"组合图"最直观的方式:

fig, axd = plt.subplot_mosaic(
    [["A", "B"],
     ["A", "C"]],
    figsize=(8, 6),
    layout="constrained",   # 自动避免子图重叠
)

axd["A"].plot(np.linspace(0, 1, 100), np.random.rand(100))
axd["A"].set_title("A(跨两行)")
axd["B"].plot(np.linspace(0, 1, 100), np.random.rand(100))
axd["C"].plot(np.linspace(0, 1, 100), np.random.rand(100))

axd 是一个字典,键就是字符串里的字符。第 11 节的"能带 + DOS 组合图"会用它。

4.4 GridSpec(精细控制尺寸比例)

当你需要"某个子图占 3 份宽、另一个占 1 份宽"时:

fig = plt.figure(figsize=(10, 4))
gs = fig.add_gridspec(1, 3, width_ratios=[3, 1, 1], wspace=0.15)

ax_main = fig.add_subplot(gs[0, 0])   # 主图,占 3 份
ax_dos  = fig.add_subplot(gs[0, 1], sharey=ax_main)  # 右侧小图,共享 y 轴
ax_other= fig.add_subplot(gs[0, 2], sharey=ax_main)

ax_main.plot([1, 2, 3], [1, 4, 9])
ax_dos.plot([0, 1], [0, 10])
ax_other.plot([0, 1], [0, 10])

5. 常见图类型(每种配代码)

先准备一套反复用到的小数据:

import numpy as np

rng = np.random.default_rng(42)   # 固定随机种子,保证可复现
x = np.linspace(0, 10, 50)
y = 2.0 * x + rng.normal(0, 1.5, x.size)   # 带噪声的线性关系

5.1 折线 plot

折线 / 曲线图是"吸附能随覆盖度变化""反应坐标能量"等场景的基本款。最常用参数:
color(颜色)、linestyle/ls(线型 '-' '--' '-.' ':')、linewidth/lw、
marker('o' 's' '^' 'D')、markersize/ms、label(配合图例)。

fig, ax = plt.subplots(figsize=(6, 4))
x = np.linspace(0, 10, 100)
ax.plot(x, np.sin(x), color="tab:blue", linestyle="-", linewidth=1.5,
        marker="o", markersize=4, markevery=10, label="sin")
ax.plot(x, np.cos(x), color="tab:red", linestyle="--", linewidth=1.5,
        label="cos")
ax.legend()

markevery=10 表示"每隔 10 个点画一个 marker",能避免数据点密集时 marker 糊成一团。

5.2 散点 scatter

适合"机器学习势函数的能量 vs DFT 能量对比(parity plot)"这类图。核心参数:s(点大小,
可以是数组)、c(颜色,可以是数组 + cmap)、alpha、edgecolors。

fig, ax = plt.subplots(figsize=(6, 6))
n = 200
x = rng.uniform(-5, 5, n)
y = x + rng.normal(0, 0.4, n)          # 模拟"预测值 vs 真值"接近 y=x
err = np.abs(y - x)

sc = ax.scatter(x, y, c=err, cmap="viridis", s=30, alpha=0.8, edgecolors="k", linewidths=0.3)
ax.plot([-6, 6], [-6, 6], "r--", lw=1, label="y = x")
ax.set_xlabel("DFT 能量 / eV")
ax.set_ylabel("ML 势预测能量 / eV")
ax.set_aspect("equal")
ax.legend()
fig.colorbar(sc, ax=ax, label="绝对误差 / eV")

5.3 柱状 bar

柱状图用于"不同催化剂 / 不同活性位点的性能对比"。注意 bar 的 height 是"柱高",
x 是柱的横坐标(可以传浮点数精确控制位置,实现分组柱)。

fig, ax = plt.subplots(figsize=(7, 4))
sites = ["Fe-N4", "Co-N4", "Ni-N4", "Cu-N4"]
overpotential = [0.42, 0.35, 0.61, 0.88]     # CO2RR 过电位 / V,越小越好

bars = ax.bar(sites, overpotential, width=0.6, color="tab:blue",
              edgecolor="k", linewidth=0.5)
ax.set_ylabel("过电位 $\eta$ / V")
ax.set_ylim(0, 1.0)
ax.axhline(0.35, color="r", linestyle="--", label="Co-N4 参考")   # 加一条参考线
ax.legend()

# 在每根柱顶标注数值
for rect in bars:
    h = rect.get_height()
    ax.text(rect.get_x() + rect.get_width() / 2, h + 0.01, f"{h:.2f}",
            ha="center", va="bottom", fontsize=9)

使用 LaTeX 风格数学文本($\eta$)需要启用 mathtext,Matplotlib 内置支持
(无需安装 LaTeX),书写见第 9 节。

5.4 误差棒 errorbar

"吸附能 / 反应能" 常伴随多次计算或模型误差。errorbar 核心参数:yerr(可标量、
数组、或 [low, high] 上下不对称误差)、xerr、fmt(点 + 线样式,如 'o')、
capsize(误差棒两端横线长度)、elinewidth、ecolor。

fig, ax = plt.subplots(figsize=(7, 4))
catalysts = ["Cu(111)", "Cu(100)", "Cu(211)", "Zn-Cu"]
E_ads = [-0.85, -0.72, -0.93, -1.05]        # CO 吸附能 / eV
err = [0.08, 0.06, 0.10, 0.07]              # 标准差

ax.errorbar(catalysts, E_ads, yerr=err, fmt="o", markersize=7,
            capsize=4, capthick=1.5, elinewidth=1.2, ecolor="tab:gray",
            color="tab:red", label="CO 吸附能")
ax.axhline(0, color="k", lw=0.8)
ax.set_ylabel("吸附能 $E_{ads}$ / eV")
ax.legend()

5.5 直方图 hist

分析"大量结构的吸附能分布""MD 模拟中键长的分布"时用。核心参数:bins、density
(True 时归一化成概率密度,面积和为 1)、alpha、histtype。

fig, ax = plt.subplots(figsize=(6, 4))
energies = rng.normal(-0.5, 0.2, 5000)      # 模拟 5000 个构型的吸附能

ax.hist(energies, bins=40, density=True, alpha=0.7,
        color="tab:green", edgecolor="w", histtype="stepfilled")
ax.set_xlabel("吸附能 / eV")
ax.set_ylabel("概率密度")

5.6 箱线图 boxplot

比较"多组样本的分布(中位数、四分位、离群点)",比"只画平均值 + 误差棒"信息更丰富。
注意 3.10 中推荐用 tick_labels 指定 x 轴标签(旧的 labels 参数已弃用但仍可用)。

fig, ax = plt.subplots(figsize=(7, 4))
rng = np.random.default_rng(7)
# 四组催化剂的 CO2RR 法拉第效率(%) 分布
data = [rng.normal(78, 5, 50),
        rng.normal(65, 8, 50),
        rng.normal(52, 6, 50),
        rng.normal(88, 4, 50)]

bp = ax.boxplot(data, tick_labels=["Cat-A", "Cat-B", "Cat-C", "Cat-D"],
                patch_artist=True, showmeans=True, showfliers=True)
colors = ["tab:blue", "tab:orange", "tab:green", "tab:red"]
for patch, c in zip(bp["boxes"], colors):
    patch.set_facecolor(c)
    patch.set_alpha(0.6)
ax.set_ylabel("法拉第效率 / %")

5.7 等高线 contour / contourf

画二维势能面(PES)切片、反应坐标扫描。contourf 是填充色块版,contour 是线条版,
两者常叠加使用(色块 + 等值线)。

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))

# 构造一个双势阱势能面,模拟两个反应坐标 r1, r2 上的自由能
r1 = np.linspace(-2, 2, 200)
r2 = np.linspace(-2, 2, 200)
R1, R2 = np.meshgrid(r1, r2)
Z = (R1**2 - 1)**2 + R2**2 + 0.5 * R1 * R2   # 一个形如"双谷"的势能面

cf = ax1.contourf(R1, R2, Z, levels=20, cmap="viridis")
fig.colorbar(cf, ax=ax1, label="势能")
ax1.set_title("contourf")

cs = ax2.contour(R1, R2, Z, levels=np.arange(0, 8, 0.5), cmap="viridis")
ax2.clabel(cs, inline=True, fontsize=7, fmt="%.1f")   # 在线上标数值
ax2.set_title("contour(等值线)")

5.8 热力图 imshow / pcolormesh

imshow 画"规则网格的矩阵"(如 ML 势的力常数矩阵、相似度矩阵、混淆矩阵),
pcolormesh 画"非均匀网格 / 真实坐标的四边形",前者快、后者坐标更灵活。

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))

# imshow:规则矩阵,origin 决定行方向
M = rng.normal(0, 1, (20, 20))
im = ax1.imshow(M, cmap="coolwarm", origin="lower", aspect="auto")
fig.colorbar(im, ax=ax1, label="值")
ax1.set_title("imshow(矩阵)")

# pcolormesh:非均匀坐标,shading='auto' 是 3.x 推荐写法
x = np.linspace(0, 5, 30)
y = np.linspace(0, 3, 20)
X, Y = np.meshgrid(x, y)
Z = np.sin(X) * np.cos(Y)
pc = ax2.pcolormesh(X, Y, Z, cmap="viridis", shading="auto")
fig.colorbar(pc, ax=ax2, label="sin(x)cos(y)")
ax2.set_title("pcolormesh(真实坐标)")

imshow 的坑:矩阵第一行默认画在上方(像图像)。物理坐标通常希望第一行在下方,
所以记得 origin="lower";配合 extent=[xmin,xmax,ymin,ymax] 设置真实坐标范围。


6. 坐标轴:标签、范围、刻度、双 y 轴、对数坐标

6.1 标签与范围

fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x, np.sin(x))

ax.set_xlabel("反应坐标 r / Å", fontsize=13)      # 推荐带单位
ax.set_ylabel("能量 E / eV", fontsize=13)
ax.set_xlim(0, 10)        # 手动设范围;省略则自动
ax.set_ylim(-1.2, 1.2)

6.2 刻度控制

能带图要把 x 轴刻度换成高对称点符号(Γ、X、M...),靠 set_xticks + set_xticklabels:

fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(np.linspace(0, 3, 50), np.sin(np.linspace(0, 3, 50)))

high_sym = [0, 1, 2, 3]          # 高对称点的位置(k 路径累计长度)
labels = ["Γ", "X", "M", "Γ"]    # 对应符号
ax.set_xticks(high_sym)
ax.set_xticklabels(labels, fontsize=12)
# 在高对称点处画竖直虚线
for k in high_sym:
    ax.axvline(k, color="k", linestyle="--", linewidth=0.7, alpha=0.6)
ax.set_ylabel("E - E$_F$ / eV")

刻度细节统一交给 tick_params(方向、长度、字号、次要刻度):

ax.tick_params(axis="both", direction="in", length=4, width=1.2,
               labelsize=11, top=True, right=True)
ax.minorticks_on()   # 打开次要刻度

6.3 双 y 轴 twinx

典型的场景:一张图里同时画"电流密度 vs 电位"和"法拉第效率 vs 电位",量纲不同。

fig, ax1 = plt.subplots(figsize=(7, 4))
potential = np.linspace(-0.2, -1.0, 50)
current = 50 * np.exp(potential / 0.3)         # 电流密度随过电位指数增长
faradaic = 100 / (1 + np.exp(-(potential + 0.6) / 0.08))  # FE 的 S 曲线

ax1.plot(potential, current, "tab:blue", label="电流密度")
ax1.set_xlabel("电位 vs. RHE / V")
ax1.set_ylabel("电流密度 / mA cm$^{-2}$", color="tab:blue")
ax1.tick_params(axis="y", labelcolor="tab:blue")

ax2 = ax1.twinx()                               # 共享 x 轴,独立右 y 轴
ax2.plot(potential, faradaic, "tab:red", label="法拉第效率")
ax2.set_ylabel("法拉第效率 / %", color="tab:red")
ax2.tick_params(axis="y", labelcolor="tab:red")

# 两条曲线合并到一个图例
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc="best")

6.4 对数坐标

吸附等温线、反应速率常数的 Arrhenius 图经常要对数:

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
x = np.logspace(0, 5, 50)         # 10^0 ~ 10^5

ax1.plot(x, x**2)
ax1.set_xscale("log")             # 只 x 轴取 log
ax1.set_yscale("log")             # x、y 都 log(双对数图)
ax1.set_title("log-log")

# Arrhenius:ln k = ln A - Ea/(RT) = f(1/T)
T = np.linspace(300, 600, 100)
k = 1e10 * np.exp(-50000 / (8.314 * T))     # Ea = 50 kJ/mol
ax2.plot(1000 / T, np.log(k))
ax2.set_xlabel("1000 / T  (K$^{-1}$)")
ax2.set_ylabel("ln k")
ax2.set_title("Arrhenius 图")

需要更精细时还有 symlog(对称对数,可跨越 0 和负数)、logit。化学出图 99% 用
set_xscale("log") / set_yscale("log") 就够了。


7. 标题、图例 legend、注释 annotate

7.1 标题

fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x, np.sin(x))
ax.set_title("(a) 反应势能曲线", fontsize=13, pad=10)   # pad 控制标题与图间距
fig.suptitle("图 1", fontsize=14)                        # 画布总标题(多子图时用)

7.2 图例 legend

ax.plot(x, np.sin(x), label="exp. data")
ax.plot(x, np.cos(x), label="DFT calc.")
ax.legend(loc="best",            # 自动挑不挡数据的位置
          frameon=True,          # 是否画边框
          framealpha=0.8,        # 边框透明度
          ncol=2,                # 分两列
          fontsize=10,
          handlelength=2.0)      # 图例中线的长度

loc 常用取值:'best'、'upper right'、'upper left'、'lower left'、
'outside'(放图外)。也可以用 bbox_to_anchor=(1.02, 1) 精确放图外:

ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1.0), borderaxespad=0)

7.3 注释 annotate / text

annotate 带箭头,适合标注"过渡态""限速步骤";text 就是纯文字。

fig, ax = plt.subplots(figsize=(6, 4))
x = np.linspace(0, 3, 100)
ax.plot(x, (x - 1.2)**2)

ax.annotate("过渡态 TS",
            xy=(1.2, 0.0),            # 箭头指向的点
            xytext=(1.6, 1.5),        # 文字位置
            fontsize=11,
            arrowprops=dict(arrowstyle="->", color="k", lw=1.2))

ax.text(0.1, 2.0, "势垒 $E_a$", fontsize=11)   # 纯文字,无箭头

xy 支持坐标转换:默认是"数据坐标",可改成 xycoords="axes fraction" 用"轴内比例坐标"。


8. 配色与 colormap

8.1 colormap 的分类(先理解再选)

类型用途推荐慎用
顺序 sequential单一方向单调变化(吸附能大小、浓度)viridis plasma cividis Blues Greysjet
发散 diverging有正负、有"零点"(电荷差分、结合能偏差)RdBu_r coolwarm seismic bwr—
定性 qualitative离散分类(不同元素 / 不同催化剂)tab10 Set1 Set2 Pairedjet
循环 cyclic周期性(角度、相)twilight hsv—

色盲友好:viridis(3.x 默认)、cividis、plasma 都是感知均匀 + 色盲友好的。
尽量避免 jet:它亮度不均匀,会在数据中制造"不存在的边界",是科研配色的头号坑。

8.2 使用 colormap

3.10 推荐用 matplotlib.colormaps 注册表获取 colormap 对象(旧 matplotlib.cm.get_cmap
已在 3.9 移除):

import matplotlib

cmap = matplotlib.colormaps["viridis"]          # 推荐:从全局注册表取 colormap
cmap = matplotlib.colormaps["RdBu_r"]
print(list(matplotlib.colormaps)[:10])          # 打印前 10 个可用名称

说明:matplotlib.colormaps 是一个全局的 ColormapRegistry 实例,["名字"] 取对象。
不要在脚本里 matplotlib.colormaps.get_cmap(...) 与旧教程的 plt.cm.get_cmap 混用,
后者已在 3.9 移除。

8.3 连续 colormap + 居中归一化 TwoSlopeNorm

画"电荷密度差 / 结合能偏差"这类有正有负、且希望 0 对应白色的图,用 TwoSlopeNorm:

import matplotlib.colors as mcolors

data = rng.normal(0, 0.5, (30, 30))     # 有正有负
norm = mcolors.TwoSlopeNorm(vmin=-1.5, vcenter=0, vmax=1.5)

fig, ax = plt.subplots(figsize=(5, 5))
im = ax.imshow(data, cmap="RdBu_r", norm=norm, origin="lower")
fig.colorbar(im, ax=ax, label="Δρ (e/Å$^3$)")

8.4 离散 colormap ListedColormap + BoundaryNorm

把连续数据"分档"着色(比如吸附强度分档:弱 / 中 / 强):

import matplotlib.colors as mcolors

bounds = [0, 0.3, 0.6, 1.0]                       # 边界
cmap = mcolors.ListedColormap(["tab:green", "tab:orange", "tab:red"])
norm = mcolors.BoundaryNorm(bounds, cmap.N)

Z = rng.uniform(0, 1, (20, 20))
fig, ax = plt.subplots(figsize=(5, 5))
im = ax.imshow(Z, cmap=cmap, norm=norm, origin="lower")
fig.colorbar(im, ax=ax, ticks=[0.15, 0.45, 0.8])  # 刻度放在每个档位中间

8.5 论文常用配色

  • 单色系 + 黑白打印友好:tab:blue tab:red tab:gray 系列(Tableau 色,区分度高)。
  • Paul Tol 色板(色盲友好):常用 #332288(蓝) #117733(绿) #44AA99(青)
    #CC6677(红) #DDCC77(黄) #882255(紫)。
  • 多曲线推荐循环:直接传 color 列表手动指定,别依赖默认循环色。

9. 样式与字体:rcParams、中文与负号、字号统一

9.1 全局 rcParams(脚本开头统一设置)

把下面这段放在每个绘图脚本最前面,一次设置全局生效,避免每个图都重复调:

import matplotlib
import matplotlib.pyplot as plt

matplotlib.rcParams.update({
    # ---- 中文 + 负号(Windows 下关键!)----
    "font.sans-serif": ["Microsoft YaHei", "SimHei", "SimSun", "KaiTi"],
    "axes.unicode_minus": False,   # 用 ASCII 减号,否则负号显示成方块

    # ---- 字号统一(投稿常用 7~10 pt,这里给 11 便于屏幕看)----
    "font.size": 11,
    "axes.labelsize": 12,
    "axes.titlesize": 13,
    "xtick.labelsize": 10,
    "ytick.labelsize": 10,
    "legend.fontsize": 10,

    # ---- 线条与边框 ----
    "axes.linewidth": 1.2,
    "lines.linewidth": 1.5,
    "xtick.direction": "in",
    "ytick.direction": "in",

    # ---- 分辨率 ----
    "figure.dpi": 150,
    "savefig.dpi": 300,
})

9.2 中文显示的准确做法(Windows)

Windows 自带的可用中文字体:SimHei(黑体,.ttf)、Microsoft YaHei(微软雅黑,.ttc)、
SimSun(宋体)、KaiTi(楷体)、FangSong(仿宋)。

第 9.1 节把 font.sans-serif 设成列表后,Matplotlib 会按顺序找第一个已安装的字体。
但有两个常见坑:

  1. 字体缓存没刷新:装好字体后 Matplotlib 可能还没把它登记进缓存。先检查:
from matplotlib import font_manager
names = {f.name for f in font_manager.fontManager.ttflist}
print("SimHei" in names, "Microsoft YaHei" in names)

若返回 False,手动注册并重建缓存:

from matplotlib import font_manager
font_manager.fontManager.addfont(r"C:\Windows\Fonts\simhei.ttf")   # 直接指定字体文件路径
font_manager._rebuild()
  1. .ttc 字体(微软雅黑)偶尔识别失败:此时把 "SimHei" 排在列表最前面更稳妥,
    因为 SimHei 是单字体 .ttf,兼容性最好。

结论:font.sans-serif 用列表 + axes.unicode_minus=False,优先 SimHei,
这基本能解决 99% 的 Windows 中文乱码和负号问题。

9.3 数学符号(mathtext)

Matplotlib 内置 mathtext,用 $...$ 写数学表达式,无需安装 LaTeX。化学常用:

ax.set_ylabel(r"能量 $E - E_F$ / eV")
ax.set_xlabel(r"反应坐标 $\xi$ / Å")
ax.set_title(r"势垒 $E_a$ = 0.45 eV")
# 下标:$E_{ads}$;上标:$^{-2}$;希腊字母:$\Delta G^{\ddagger}$

注意:r"..." 原始字符串可避免 \d 等被当作转义。若想用真 LaTeX 渲染,
设置 matplotlib.rcParams["text.usetex"] = True(需额外装 TeX,一般没必要)。

9.4 已有样式表

Matplotlib 自带多种样式,也可叠加自定义:

plt.style.use("seaborn-v0_8-whitegrid")   # 类似 seaborn 的网格样式
# 或叠加列表:plt.style.use(["seaborn-v0_8", "我的自定义.mplstyle"])

"seaborn" 等旧样式名在 3.6+ 已弃用,3.10 里用 "seaborn-v0_8-*" 前缀。


10. 保存导出:dpi、矢量格式、bbox_inches

10.1 核心结论

  • 投稿一律用矢量格式:pdf、svg(eps 也可,但建议优先 pdf/svg)。
    矢量图可无限放大不失真,期刊编辑 / LaTeX 需要矢量。
  • dpi 只影响栅格内容:png/tiff 是位图,必须高 dpi(≥300);矢量图中若
    嵌入了 imshow 等栅格元素,也要设 dpi 保证那些像素块清晰。
  • bbox_inches="tight":自动裁掉四周多余留白,几乎必加。
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x, np.sin(x))

fig.savefig("fig.pdf", bbox_inches="tight")          # 投稿首选:矢量 PDF
fig.savefig("fig.svg", bbox_inches="tight")          # 矢量 SVG,可导入 Inkscape / PPT
fig.savefig("fig.png", dpi=300, bbox_inches="tight") # 报告 / 网页:高清位图
fig.savefig("fig.tif", dpi=600, bbox_inches="tight",
            pil_kwargs={"compression": "tiff_lzw"})   # 部分期刊要 TIFF

常用 figsize(英寸):单栏 ≈ (3.5, 2.6);双栏 ≈ (7.0, 2.6)。配合
dpi=300,单栏图实际像素约 1050×780,足够清晰。

10.2 常用保存参数速查

参数作用推荐值
dpi栅格分辨率300(投稿)/ 150(预览)
bbox_inches裁边"tight"
transparent透明背景True(做 PPT 时)
pad_inches裁边后额外留白0.05
format显式指定格式"pdf" "svg" "png"
fig.savefig("fig.svg", bbox_inches="tight", transparent=True, pad_inches=0.05)

11. 科研实战示例(重点)

以下每个示例都是"可直接运行 + 换成你的真实数据就能用"的模板。数据用 NumPy 模拟,
注释里说明如何接真实数据。

11.1 能带结构图

真实数据通常来自 VASP 的 EIGENVAL / vasprun.xml(推荐用 pymatgen 的
BandStructure 或 vaspkit 导出)。这里演示通用画法:x 轴是 k 路径累计长度,
y 轴是相对费米能级的能量。

import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(0)

# ---- 模拟数据:4 段 k 路径,每段 60 个点,5 条能带 ----
segments = [(0, 1), (1, 2), (2, 3), (3, 4)]      # 各段端点
n_per = 60
n_bands = 5

k_path = []
for a, b in segments:
    k_path.append(np.linspace(a, b, n_per))
k_path = np.concatenate(k_path)

# 用一个"正弦 + 微扰"模拟 5 条能带(价带向下、导带向上)
bands = np.zeros((n_bands, k_path.size))
for b in range(n_bands):
    bands[b] = (b - 2) * 1.5 + 0.8 * np.sin(2 * np.pi * k_path / 4 + b)

fig, ax = plt.subplots(figsize=(7, 5))

for b in range(n_bands):
    ax.plot(k_path, bands[b], color="tab:blue", lw=1.2)

ax.axhline(0, color="k", lw=0.8, ls="--")        # 费米能级 E_F = 0

# 高对称点:用竖直虚线分隔 + 下方标注符号
k_labels_pos = [0, 1, 2, 3, 4]
k_labels = ["Γ", "X", "M", "Y", "Γ"]
for k in k_labels_pos:
    ax.axvline(k, color="gray", lw=0.7, ls="--", alpha=0.6)

ax.set_xticks(k_labels_pos)
ax.set_xticklabels(k_labels, fontsize=13)
ax.set_xlim(0, 4)
ax.set_ylim(-4, 4)
ax.set_ylabel(r"$E - E_F$ / eV", fontsize=13)
ax.tick_params(direction="in", top=True, right=True)
ax.set_title("能带结构(示意)", fontsize=13)

fig.savefig("band_structure.pdf", bbox_inches="tight")

11.2 态密度 DOS 图

自旋极化计算通常画"自旋向上(正值)/ 自旋向下(负值)",用 fill_between 填充:

import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(1)
E = np.linspace(-6, 6, 400)                      # 能量轴

def gaussian(x, mu, sigma):
    return np.exp(-(x - mu)**2 / (2 * sigma**2)) / (sigma * np.sqrt(2 * np.pi))

# 用几个高斯峰模拟 DOS(spin up 为正,spin down 为负)
dos_up = (2.0 * gaussian(E, -1.0, 0.6) + 1.2 * gaussian(E, 0.8, 0.4)
          + 0.8 * gaussian(E, 2.5, 0.5))
dos_dn = (1.8 * gaussian(E, -0.6, 0.7) + 1.0 * gaussian(E, 1.5, 0.5)
          + 0.6 * gaussian(E, 3.0, 0.4))

fig, ax = plt.subplots(figsize=(6, 6))

ax.fill_between(E, dos_up, 0, color="tab:blue", alpha=0.5, label="spin up")
ax.fill_between(E, -dos_dn, 0, color="tab:red", alpha=0.5, label="spin down")

ax.axvline(0, color="k", lw=0.8, ls="--")        # 费米能级
ax.set_xlabel(r"$E - E_F$ / eV", fontsize=13)
ax.set_ylabel("DOS / states eV$^{-1}$", fontsize=13)
ax.legend()
ax.set_title("态密度 DOS(自旋极化,示意)", fontsize=13)

fig.savefig("dos.pdf", bbox_inches="tight")

真实数据:VASP 的 DOSCAR / vasprun.xml 可用 pymatgen 的 DOSPlotter 或
vaspkit 导出 .dat 后 np.loadtxt 读取,再套用上面的填充画法。

11.3 能带 + DOS 组合图(subplot_mosaic,共享 y 轴)

这是凝聚态 / 催化论文最常见的"组合图"。DOS 转 90° 放在右侧,与能带共享能量轴:

import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(2)
k = np.linspace(0, 4, 200)
E = np.linspace(-6, 6, 400)

# 模拟能带
bands = np.array([(b - 2) * 1.5 + 0.8 * np.sin(2 * np.pi * k / 4 + b)
                  for b in range(5)])
# 模拟 DOS
dos = (2.0 * np.exp(-(E - 0.5)**2 / 0.5) + 1.0 * np.exp(-(E + 1.5)**2 / 0.6))

fig, axd = plt.subplot_mosaic(
    [["band", "dos"]],
    figsize=(9, 5),
    width_ratios=[3, 1],
    layout="constrained",
)

ax_band = axd["band"]
ax_dos = axd["dos"]

# 左:能带
for b in range(5):
    ax_band.plot(k, bands[b], color="tab:blue", lw=1.2)
ax_band.axhline(0, color="k", lw=0.8, ls="--")
for kp in [0, 1, 2, 3, 4]:
    ax_band.axvline(kp, color="gray", lw=0.7, ls="--", alpha=0.6)
ax_band.set_xticks([0, 1, 2, 3, 4])
ax_band.set_xticklabels(["Γ", "X", "M", "Y", "Γ"], fontsize=12)
ax_band.set_ylabel(r"$E - E_F$ / eV", fontsize=13)
ax_band.set_xlim(0, 4)
ax_band.set_ylim(-6, 6)

# 右:DOS(横着画,与能带共享 y 轴)
ax_dos.plot(dos, E, color="tab:red", lw=1.5)
ax_dos.fill_betweenx(E, dos, 0, color="tab:red", alpha=0.4)
ax_dos.axhline(0, color="k", lw=0.8, ls="--")
ax_dos.set_ylim(-6, 6)
ax_dos.set_xlabel("DOS", fontsize=12)
ax_dos.set_yticks([])          # 隐藏右侧 DOS 图的 y 轴刻度,避免重复

ax_band.set_title("能带结构 + 态密度(示意)", fontsize=13)
fig.savefig("band_dos.pdf", bbox_inches="tight")

技巧:width_ratios=[3, 1] 让能带占 3 份宽、DOS 占 1 份;fill_betweenx 是
"沿 y 轴方向填充"版本。若要真正共享同一能量轴刻度,可给 subplot_mosaic
传 sharey=True(但这里 DOS 有自己的刻度语义,故手动控制)。

11.4 催化性能对比柱状图(分组柱)

比较"不同催化剂在不同电位下的法拉第效率 / 产物选择性",用分组柱 + 图例:

import numpy as np
import matplotlib.pyplot as plt

catalysts = ["Cu", "Ag", "Au", "Zn"]
fe_co = [12, 5, 8, 3]     # CO 法拉第效率 / %
fe_h2 = [30, 45, 25, 60]  # H2 法拉第效率 / %
fe_hcooh = [45, 20, 55, 15]  # HCOOH 法拉第效率 / %

x = np.arange(len(catalysts))
width = 0.25

fig, ax = plt.subplots(figsize=(7, 5))
r1 = ax.bar(x - width, fe_co, width, label="CO", color="tab:blue")
r2 = ax.bar(x, fe_h2, width, label="H$_2$", color="tab:gray")
r3 = ax.bar(x + width, fe_hcooh, width, label="HCOOH", color="tab:orange")

ax.set_ylabel("法拉第效率 / %", fontsize=12)
ax.set_xticks(x)
ax.set_xticklabels(catalysts, fontsize=12)
ax.set_ylim(0, 100)
ax.legend(ncol=3)
ax.set_title("CO$_2$RR 产物选择性对比(示意)", fontsize=13)

fig.savefig("faradaic_bar.pdf", bbox_inches="tight")

11.5 吸附能误差棒图

不同金属 / 晶面对同一吸附质的吸附能,含误差棒,并加"火山曲线"参考线:

import numpy as np
import matplotlib.pyplot as plt

surfaces = ["Cu(111)", "Cu(211)", "Zn(101)", "Pd(111)", "Pt(111)"]
E_CO = [-0.51, -0.78, -0.42, -1.20, -1.55]   # CO 吸附能 / eV
err = [0.05, 0.09, 0.04, 0.07, 0.06]

fig, ax = plt.subplots(figsize=(8, 5))
ax.errorbar(surfaces, E_CO, yerr=err, fmt="s", markersize=9,
            capsize=5, capthick=1.5, elinewidth=1.3,
            color="tab:red", ecolor="tab:gray", label=r"$E_{ads}$(CO)")

ax.axhline(0, color="k", lw=0.8)
# 标注"弱吸附"和"强吸附"区
ax.axhspan(-1.4, -0.9, color="tab:blue", alpha=0.12, label="强吸附区")
ax.axhspan(-0.3, 0.0, color="tab:green", alpha=0.12, label="弱吸附区")

ax.set_ylabel(r"吸附能 $E_{ads}$ / eV", fontsize=13)
ax.tick_params(direction="in", top=True, right=True)
ax.legend()
ax.set_title("CO 吸附能对比(示意)", fontsize=13)

fig.savefig("adsorption_errorbar.pdf", bbox_inches="tight")

11.6 反应势能曲线(反应坐标图)

标注反应物 / 过渡态 / 产物,以及活化能 Ea 和反应能 ΔG:

import numpy as np
import matplotlib.pyplot as plt

# 反应坐标(离散的几个关键点)
rc = [0.0, 0.4, 1.0, 1.6, 2.0]           # 反应物 -> TS -> 中间体 -> 产物
E = [0.0, 0.85, 0.30, 1.10, -0.45]       # 相对能量 / eV(TS 是鞍点)

fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(rc, E, marker="o", markersize=8, color="tab:blue", lw=2.0)

# 标注关键点
ax.annotate("反应物", xy=(0.0, 0.0), xytext=(0.05, 0.35), fontsize=11,
            arrowprops=dict(arrowstyle="->", lw=1.0))
ax.annotate("TS1", xy=(0.4, 0.85), xytext=(0.5, 1.15), fontsize=11,
            arrowprops=dict(arrowstyle="->", lw=1.0))
ax.annotate("TS2", xy=(1.6, 1.10), xytext=(1.25, 1.45), fontsize=11,
            arrowprops=dict(arrowstyle="->", lw=1.0))
ax.annotate("产物", xy=(2.0, -0.45), xytext=(1.6, -1.0), fontsize=11,
            arrowprops=dict(arrowstyle="->", lw=1.0))

# 用双箭头标注能垒 / 反应能
ax.annotate("", xy=(0.4, 0.0), xytext=(0.4, 0.85),
            arrowprops=dict(arrowstyle="<->", color="r", lw=1.5))
ax.text(0.43, 0.4, r"$E_a$ = 0.85 eV", color="r", fontsize=11)

ax.annotate("", xy=(2.0, -0.45), xytext=(2.0, 0.0),
            arrowprops=dict(arrowstyle="<->", color="g", lw=1.5))
ax.text(2.03, -0.25, r"$\Delta G$ = -0.45 eV", color="g", fontsize=11)

ax.set_xlabel("反应坐标", fontsize=13)
ax.set_ylabel("相对能量 / eV", fontsize=13)
ax.set_xticks([])          # 反应坐标没有物理刻度,隐藏数字
ax.set_ylim(-1.3, 1.6)
ax.set_title("反应势能曲线(示意)", fontsize=13)

fig.savefig("reaction_path.pdf", bbox_inches="tight")

12. seaborn 简要提及(何时用)

Seaborn 是构建在 Matplotlib 之上的高层统计绘图库,底层仍是 Matplotlib 对象。
原则:

  • 探索数据 / 快速统计图:用 seaborn(boxplot violinplot kdeplot heatmap
    pairplot 一行出图,比手写 Matplotlib 快得多)。
  • 投稿精修图:用 seaborn 出初步图后,拿到它返回的 ax,再用 Matplotlib 精调。
import seaborn as sns
import numpy as np
import matplotlib.pyplot as plt

sns.set_theme(style="ticks", context="paper", font="SimHei")  # 全局样式,font 可设中文

rng = np.random.default_rng(3)
data = {"cat": np.repeat(["A", "B", "C", "D"], 50),
        "value": rng.normal(0, 1, 200)}

fig, ax = plt.subplots(figsize=(6, 4))
sns.boxplot(data=data, x="cat", y="value", ax=ax)   # 显式传入 ax,保持 OO 风格
ax.set_ylabel("吸附能 / eV")
ax.set_title("seaborn 箱线图(底层是 Matplotlib)")

fig.savefig("sns_box.pdf", bbox_inches="tight")

关键点:sns.boxplot(..., ax=ax) 里显式传 ax,seaborn 就会画到你的 Axes 上,
后续继续用 ax.set_* 精调,两个库完全兼容。


13. 常见坑(务必先读)

  1. 中文字符显示成方框(tofu):

    • 根因:Matplotlib 默认字体(DejaVu Sans)不含中文字形。
    • 解决:设 font.sans-serif 列表(优先 SimHei / Microsoft YaHei)+ 确认字体已
      登记(第 9.2 节的检查代码)。必要时 font_manager.fontManager.addfont(...)。
  2. 负号 / 减号显示成方块:

    • 根因:默认用 Unicode 减号 −,中文字体里缺这个字形。
    • 解决:matplotlib.rcParams["axes.unicode_minus"] = False。
  3. 子图重叠 / 标题被裁掉:

    • 解决:plt.subplots(..., layout="constrained") 或 fig.tight_layout()。
      二选一即可,别同时用(会冲突)。subplot_mosaic 里用 layout="constrained"。
  4. 保存的 png 模糊 / 马赛克:

    • 根因:dpi 太低(默认 savefig.dpi 可能 < 150)。
    • 解决:fig.savefig("x.png", dpi=300)。投稿用 pdf/svg 矢量格式彻底规避。
  5. 用 jet colormap 画出"假边界":

    • 根因:jet 亮度不均匀,人为制造数据里不存在的结构。
    • 解决:换成 viridis / plasma / cividis(顺序),RdBu_r / coolwarm(发散)。
  6. plt.plot 与 ax.plot 混用导致画错子图:

    • 根因:pyplot 操作的是"隐式当前 Axes",多子图时状态错乱。
    • 解决:全程显式用 ax.xxx(...),plt 只用于 subplots / show / rcParams。
  7. 图例盖住数据:

    • 解决:ax.legend(loc="best") 或 bbox_to_anchor=(1.02, 1) 放到图外。
  8. imshow 图像上下颠倒:

    • 解决:物理数据加 origin="lower";再配 extent=[xmin,xmax,ymin,ymax] 设真实坐标。
  9. 颜色条 colorbar 尺寸/位置不对:

    • 解决:用 fig.colorbar(im, ax=ax) 显式绑定 ax,颜色条会自动对齐该 Axes 高度。
  10. 旧教程里的已删除 API:

    • matplotlib.cm.get_cmap(3.9 移除)→ 用 matplotlib.colormaps["viridis"]。
    • plt.style.use("seaborn")(3.6 弃用)→ 用 "seaborn-v0_8" 系列。
    • boxplot(..., labels=...)(3.9 弃用)→ 用 tick_labels=...。

14. 延伸资源

  • 官方画廊(Gallery):https://matplotlib.org/stable/gallery/ —— 每个图都有源码,
    直接搜图型 + 改数据。这是最高效的学习入口。
  • 官方 API 参考:https://matplotlib.org/stable/api/index.html —— 查参数只看这里。
  • Cheat Sheet:Matplotlib 官方提供官方速查表 PDF,打印贴桌上。
  • pymatgen 出图:https://pymatgen.org —— BSPlotter(能带)、DOSPlotter(态密度)、
    PhaseDiagram(相图)可直接出图,且返回 Matplotlib 对象可继续精修。
  • ASE 出图:https://wiki.fysik.dtu.dk/ase/ —— 原子结构 / 动画可视化,配合
    Matplotlib 保存。
  • "Scientific Visualization: Python + Matplotlib"(Nicolas P. Rougier,免费开源书)
    —— 进阶配色、排版、多面板布局的权威参考。
  • Paul Tol 配色:https://personal.sron.nl/~pault/ —— 色盲友好的科研配色标准。

附:一个可直接套用的"绘图脚本骨架"

# -*- coding: utf-8 -*-
"""科研出图脚本骨架:把 rcParams 设置 + 你的绘图函数组织起来。"""
import numpy as np
import matplotlib
import matplotlib.pyplot as plt

# ---------- 1. 全局样式(中文 + 负号 + 字号 + 分辨率)----------
matplotlib.rcParams.update({
    "font.sans-serif": ["Microsoft YaHei", "SimHei", "SimSun"],
    "axes.unicode_minus": False,
    "font.size": 11,
    "axes.labelsize": 12,
    "axes.titlesize": 13,
    "xtick.labelsize": 10,
    "ytick.labelsize": 10,
    "legend.fontsize": 10,
    "axes.linewidth": 1.2,
    "lines.linewidth": 1.5,
    "xtick.direction": "in",
    "ytick.direction": "in",
    "figure.dpi": 150,
    "savefig.dpi": 300,
})


# ---------- 2. 你的绘图函数(只接收 ax,便于复用 / 组合)----------
def plot_my_result(data, ax):
    """在这里写具体绘图逻辑,data 换成你的 np.loadtxt / pandas 结果。"""
    ax.plot(data[:, 0], data[:, 1], label="my data")
    ax.set_xlabel("x / unit")
    ax.set_ylabel("y / unit")
    ax.legend()
    return ax


# ---------- 3. 主流程 ----------
if __name__ == "__main__":
    x = np.linspace(0, 10, 100)
    y = np.sin(x)

    fig, ax = plt.subplots(figsize=(6, 4))
    plot_my_result(np.column_stack([x, y]), ax)
    fig.savefig("result.pdf", bbox_inches="tight")   # 投稿矢量图
    fig.savefig("result.png", dpi=300, bbox_inches="tight")  # 预览位图
    plt.show()