绘图

论文里贴进去的图,通常一眼就能认出来。不是因为曲线难看,而是因为只有坐标轴上的数字字体不一样。在 LaTeX 中从根本上解决这件事的工具是 pgfplots:它不是把图当作图片接收,而是用与正文相同的排版引擎,从数值把图组出来。本页讲 pgfplotsaxis 环境和 \addplot、读取 .dat 文件、把计算交给 gnuplot 的路径,以及把 R 或 Python 画的图连同字体一起带进来的方法。文中会原样引用 TeX Live 2024 实际给出的错误信息——搜索到这里的人,多半正盯着其中某一条。

为什么贴进去的图片总和文档格格不入

原因不是分辨率,而是 字体和线宽。电子表格默认使用无衬线字体,而论文正文是衬线字体;读者哪怕说不出字体名字,也能察觉这种不协调。此外,把图片缩小以适应版心时,线条也会一起变细,于是图内的线比图外的线更细。pgfplots 只接收坐标,刻度数字和图例都 用文档自身的字体、经文档自身的数学排版 放置,因此这两种偏差从原理上就不会发生。在轴标签里写 $\sin x$,得到的字形与正文公式完全一致。

第二件在实际工作中见效的事是 与数据分离。不要把坐标硬写进正文,而是让图去读 .dat.csv;测量重做时只需 换掉文件再重新编译,图就跟着变。论文、幻灯片和附录读的是同一份数值,转录错误从源头上就不会发生。表格那边也有同样思路的姊妹宏包 pgfplotstablepgfplots 本身建立在 TikZ/PGF 之上,所以整幅图位于 tikzpicture 内,TikZ 的节点和装饰可以自由混用。TikZ 本身——\draw、坐标系、为什么编译慢——属于 TikZ 那一页。

不写 \pgfplotsset{compat=1.18} 会怎样

会出现警告,而且 图形会保持旧的默认外观。用 TeX Live 2024 附带的 pgfplots 1.18.1 在不写 compat 的情况下编译,日志会给出:"Package pgfplots Warning: running in backwards compatibility mode (unsuitable tick labels; missing features). Consider writing \pgfplotsset{compat=1.18} into your preamble."。警告本身就说明了该用哪个编号,照做即可。

latex
\usepackage{pgfplots}
\pgfplotsset{compat=1.18}   % pin the release whose defaults you want

之所以有这套机制,是因为 pgfplots 每发布一版都会改动 自身的默认值。刻度标签的格式或坐标轴留白一旦改变,同一份源码产出的图也会随之改变。compat 就是声明“按哪一版的约定来排版”的钥匙,它保证重新编译五年前的稿件时图形不会移位。TeX Live 2024 附带的是 pgfplots 1.18.1(2021 年 5 月 15 日),所以在这个环境里能指定的最新值就是 compat=1.18。新文档一定要写上这一行;既有文档则 不要随手往上调——一调,图就可能微微偏移。

axis\addplot:能跑起来的最小图

tikzpicture 里放一个 axis 环境,其中每调用一次 \addplot 就叠加一条曲线。分工很清楚:与坐标框有关的一切——标签、刻度、网格、图例位置——都是 axis 的选项,而 单条曲线的外观——颜色、标记、线型——都是 \addplot 的选项。理解了这两层结构,剩下的就只是查选项名了。

latex
\begin{tikzpicture}
  \begin{axis}[
    xlabel = {$x$},
    ylabel = {$f(x)$},
    title  = {A parabola},
    grid   = major,
  ]
    \addplot[blue, domain=-3:3, samples=100] {x^2};
    \addlegendentry{$x^2$}
  \end{axis}
\end{tikzpicture}

这会在 −3 ≤ x ≤ 3 上以 100 个点采样抛物线 y = x²,画成平滑的蓝色曲线;grid=major 沿主刻度绘制浅色网格,\addlegendentry 添加一行图例。有两个细节值得记住。标签和标题一定要用花括号包住——写成 xlabel={$x$},即使里面出现逗号或 ] 也不会破坏选项解析。图例有两种写法:在每条曲线后放 \addlegendentry{…},或在 axis 选项里统一写 legend entries={A,B,...};位置用 legend pos=north west 之类设置。

选项作用
xlabel= / ylabel=x 轴与 y 轴标签;写成 xlabel={$x$} 用花括号包住,数学式也安全
title=放在图上方的标题
xmin= xmax= ymin= ymax=固定可见范围,数据增加时坐标轴也不移动
grid=grid=major 沿主刻度画网格,grid=both 连次刻度也画
legend pos=图例位置:north west 等;outer north east 可移到框外
xtick=明确刻度位置(xtick={0,1,2});xtick=data 让刻度对齐数据点
width= / height=图的成品尺寸;width=\linewidth 与版心宽度对齐
ybar / xbar改为竖向/横向柱状图;叠加多个 \addplot 会自动分组

domainsamples 的默认值,以及 sin 为什么算错

默认值是 在区间 −5:5 上取 25 个点,以及 三角函数按角度计算。前者能立刻解释曲线为什么显得有棱角;后者是 pgfplots 中“图形形状不对”最常见的原因。有意思的是,这两个默认值其实都不属于 pgfplots——它自己的 /pgfplots/samples/.initial 是空的,取值会落到下层的 PGF。在 TeX Live 2024 的 tikz.code.tex 里并排写着 \def\tikz@plot@samples{25}\def\tikz@plot@domain{-5:5},那才是真正的默认值。

关于角度这件事,看数字最快。在 TeX Live 2024 上,\pgfmathparse{sin(1)} 求得 0.01746,而 \pgfmathparse{sin(deg(1))} 得到 0.84143。前者是「1 度的正弦」,后者是「1 弧度的正弦」,deg() 就是弧度到角度的换算器:deg(1) = 57.29578。因此写 \addplot {sin(x)} 会把 sin 1° 放在 x = 1 处,得到一条周期为 360 的陌生波形。正确写法是 sin(deg(x))

latex
% wrong: pgfmath reads the argument as degrees
\addplot[domain=0:2*pi, samples=200] {sin(x)};
% right: convert radians to degrees first
\addplot[red, domain=0:2*pi, samples=200] {sin(deg(x))};

\addplot 喂数据的三种方式:函数、坐标、.dat 文件

三种方式是:{...} 中写表达式coordinates {...} 中列出点table {文件名} 读文件。前两种上面已经见过。实际工作中用得最多的是第三种:对于以空白分隔的文本文件,pgfplots 会把第一行当作列名,默认把 第 1 列作为 x、第 2 列作为 y 绘制。

data.dat
x   y
0   0.0
1   0.8
2   0.9
3   0.1
4  -0.8
5  -1.0
latex
\begin{tikzpicture}
  \begin{axis}[xlabel={$x$}, ylabel={$y$}, grid=major]
    \addplot[mark=square, teal] table {data.dat};
    % naming the columns explicitly is the safer habit:
    % \addplot table[x=x, y=y] {data.dat};
  \end{axis}
\end{tikzpicture}

列名 区分大小写。表头写的是 x,而代码写成 table[x=X, y=y],TeX Live 2024 就会停下来:"! Package pgfplots Error: Sorry, could not retrieve column 'X' from table '...'. Please check spelling (or introduce name aliases)."。在各种失败里这算温和的一种——信息本身就提示你检查拼写或添加别名。逗号分隔的 CSV 用 table[col sep=comma, x=x, y=y] {data.csv};以 #% 开头的行会作为注释跳过。需要整理数据或生成计算列时,那是姊妹宏包 pgfplotstable 的职责。

柱状图、对数轴,以及用 \addplot3 画 3D

柱状图只需给 axis 加上 ybar对数轴只需把 axis 换成另一个环境名3D 只需使用 \addplot3。不必重新学一套语法,正是 pgfplots 设计上的优点——\addplot 的写法原样通用。柱状图中叠加多个 \addplot 会自动横向错开成分组柱;若要让 x 轴显示年份这类字符串,就把 symbolic x coordsxtick=data 搭配使用。

latex
\begin{tikzpicture}
  \begin{axis}[
    ybar,
    xlabel = {Year}, ylabel = {Count},
    symbolic x coords = {2023, 2024, 2025},
    xtick = data,
  ]
    \addplot coordinates {(2023,40) (2024,55) (2025,72)};
  \end{axis}
\end{tikzpicture}

对数轴只需替换环境名:双对数用 loglogaxis,仅 x 轴取对数用 semilogxaxis,仅 y 轴取对数用 semilogyaxis。3D 方面,使用 \addplot3 会让 axis 自动变成三维;曲面指定 surf,线框指定 mesh,函数用 xy 两个变量书写。视角用 view={方位角}{仰角} 旋转。

latex
\begin{tikzpicture}
  \begin{loglogaxis}[xlabel={$x$}, ylabel={$y$}]
    \addplot[domain=1:1000, samples=50] {1/x};
  \end{loglogaxis}
\end{tikzpicture}

\begin{tikzpicture}
  \begin{axis}[xlabel={$x$}, ylabel={$y$}, zlabel={$z$}]
    \addplot3[surf, samples=30, domain=-3:3]
      {exp(-x^2 - y^2)};
  \end{axis}
\end{tikzpicture}

gnuplot 并不随 TeX Live 一起安装

这个前提很重要,所以先说:要使用 \addplot gnuplot {...},必须 自己另行安装 gnuplot。查看 TeX Live 2024 的可执行文件目录,可以找到 asy(Asymptote)和 mpost(MetaPost),但 没有 gnuplot。gnuplot 是与 TeX 无关、独立开发的绘图程序,需要通过 Homebrew 或各发行版的包管理器来安装。

那么调用它是为了什么?pgfplots 内置的解析器运行在 TeX 之上,因此不适合复杂表达式或大量采样点。写 \addplot gnuplot {...} 会把数值计算外包给 gnuplot,pgfplots 只负责绘制返回的坐标。机制朴素得出人意料:TeX 把给 gnuplot 的脚本写成文件,gnuplot 执行它并 把数表写入文件,TeX 再读回来。在 TeX Live 2024 上处理 \addplot[blue] gnuplot[domain=0:10] {sin(x)}; 时,生成的 .gnuplot 文件里排着这些指令(作业名已替换为 job):set table "job.pgf-plot.table"; set format "%.7e"; set samples 25; set dummy x; plot [x=0:10] sin(x);

从这一行可以读出两件事。第一,samples 的默认值 25 被原样传给了 gnuplot。第二,表达式是 按 gnuplot 自己的语法 传过去的——所以幂运算符不是 pgfplots^ 而是 gnuplot 的 **,三角函数也默认使用 弧度。同一条正弦曲线,用内置解析器写 {sin(deg(x))},经 gnuplot 则写 {sin(x)}。另外,由于 TeX 需要启动外部程序,必须使用 --shell-escape(也写作 -write18)。

latex
% compile with:  pdflatex --shell-escape document
\begin{tikzpicture}
  \begin{axis}[xlabel={$x$}, ylabel={$y$}]
    % gnuplot syntax: radians, and ** for powers
    \addplot[blue] gnuplot[domain=0:10] {sin(x)};
  \end{axis}
\end{tikzpicture}

在没有安装 gnuplot 的环境中运行,TeX Live 2024 会报告:"! Package pgfplots Error: Sorry, the gnuplot-result file 'job.pgf-plot.table' could not be found. Maybe you need to enable the shell-escape feature? For pdflatex, this is '>> pdflatex -shell-escape'."。如果 gnuplot 确实不存在,它上面还会出现 shell 自己的 "sh: gnuplot: command not found"。值得注意的是消息接下来说的话:你也可以对生成的文件手动执行 gnuplot <file>.gnuplotpgfplots 真正需要的只是那个 .table 文件——事实上,把手写的 .table 放到位再重新编译,在完全没有 gnuplot 的情况下也画出了图。在无法安装 gnuplot 的 CI 上,把生成好的 .table 提交进仓库就是可行的变通办法。

把 R 与 Python 的图连同字体一起带进来

分析已经在 R 或 Python 里做完了,但又不想贴 PNG——这时的答案是 让各工具导出 TikZ/PGF 代码,再用 \input 引入。图会作为文档的一部分排版,字体和公式都与正文一致。不过要先说明,这些工具 都不属于 TeX Live,需要从各自语言的包管理器安装。

在 R 中,CRAN 上的 tikzDevice 提供一种图形设备,把 R 的标准图形输出(base 绘图和 ggplot2 都算)写成 TikZ 代码。用 tikz() 打开设备,运行平常的绘图代码,再用 dev.off() 关闭,就得到一个 .tex。它的独到之处在于放置文字时 向 LaTeX 查询字符串宽度和字体度量,这正是输出能精确匹配正文字体、并且轴标签里能写 LaTeX 公式的原因。设置 standAlone=TRUE 会输出一个可单独编译的完整文档。

R
library(tikzDevice)
tikz("plot.tex", width = 4, height = 3)
plot(cars$speed, cars$dist,
     xlab = "Speed", ylab = "Distance")
dev.off()

Python 这边有两条路,而且常被混为一谈。第一条是 matplotlib 自带的 pgf 后端,不需要额外安装。先 matplotlib.use("pgf"),再 savefig("figure.pgf"),就会得到一个 .pgf 文件。关键在于:它输出的是 纯 PGF,而不是 pgfplots 代码。检查由 matplotlib 3.11.0 实际生成的文件,里面没有一个 \addplot,也没有一个 axis 环境,主体是 \pgfqpoint\pgfpathlineto\pgfsetstrokecolor 这类低层绘图命令的堆叠。换句话说,坐标轴不是 pgfplots 排出来的轴,而是用线画出来的轴。文档端需要的也只是 \usepackage{pgf},而不是 \usepackage{pgfplots}

python
import matplotlib
matplotlib.use("pgf")
import matplotlib.pyplot as plt

matplotlib.rcParams.update({
    "pgf.texsystem": "pdflatex",   # default here is xelatex
    "font.family": "serif",
    "text.usetex": True,
    "pgf.rcfonts": False,
})

fig, ax = plt.subplots(figsize=(4, 3))
ax.plot([0, 1, 2, 3], [0, 1, 4, 9])
ax.set_xlabel(r"$x$")
ax.set_ylabel(r"$x^2$")
fig.savefig("figure.pgf")

这里有 一个坑。生成的 .pgf 开头的注释会告诉你在导言区加 \usepackage{pgf}——照做之后 \input{figure.pgf},TeX Live 2024 却会停下:"! Undefined control sequence. <recently read> \mathdefault"。原因是 matplotlib 会把刻度标签包在 \mathdefault{...} 里输出,却 不把这个定义写进 .pgf。它的 backend_pgf.py 只有在 matplotlib 自己渲染 PDF 时才把定义注入内部导言区。解决只需一行:在导言区写 \providecommand{\mathdefault}[1]{#1} 即可通过。另外 pgf.texsystem 的默认值是 xelatex,所以如果正文用 pdfLaTeX 排版,最好像上面的例子那样显式指定。

latex
\documentclass{article}
\usepackage{pgf}
\usepackage{lmodern}
% matplotlib wraps tick labels in \mathdefault but never defines it
\providecommand{\mathdefault}[1]{#1}
\begin{document}
\input{figure.pgf}
\end{document}

Python 的另一条路是 tikzplotlib(旧名 matplotlib2tikz),它把 matplotlib 的图转换成 pgfplots 代码。用 tikzplotlib.save("figure.tex") 写出,再在文档中载入 \usepackage{pgfplots}\pgfplotsset{compat=...}\input{figure.tex}。由于坐标轴确实是一个 axis 环境而不是纯 PGF,你可以 在生成之后于 LaTeX 端调整刻度和图例,这正是它与 pgf 后端的决定性区别。不过要注意,tikzplotlib 目前已停止维护;其 fork matplot2tikz 正作为后继开发,API 几乎相同。两者都从 PyPI 安装,都不属于 TeX Live。

当点太多、编译迟迟不结束时

首先是 抽稀数据点,这一招最见效。pgfplots 的所有计算都通过 TeX 的宏展开完成,因此数万点的散点图确实会变慢,甚至可能触及 TeX 的内存上限。而且印在纸上时,如果相邻两点的间距小于印刷分辨率,那些点根本看不见。下面按见效程度排列。

  • 抽稀数据点。each nth point=k 每 k 个点画一个,并用 filter discard if not 等过滤器丢弃范围外的数据。散点图这样处理后外观通常没有区别。
  • 外部化(externalization)。 使用 \usepgfplotslibrary{external}\tikzexternalize,每幅图只单独编译一次成 PDF,之后仅作插入。正文重编译会大幅变轻,代价是需要 --shell-escape
  • 换用内存更大的引擎。 lualatex 的 TeX 内存限制更宽松,处理大图时不易卡死。
  • 干脆在上游画好。 如果图始终太重,导入在 R、Python 或 gnuplot 中绘制的结果(上一节)更为现实。

外部化和 gnuplot 都要求 --shell-escape,因此在 CI 或容器中构建时必须 在那边同样显式启用,否则本地能通过的构建到了那边就会失败。这类事故非常常见,建议对照 Docker / CI 页面确认配置。