R 用户从 R 切回 Python 想画同样的图,默认选项是 plotnine。它把 Grammar of Graphics 在 Python 里实现得相当成熟,API 几乎和 R 端 ggplot2 逐字对应。但渲染那一头,plotnine 落在 matplotlib 上 —— 这是 Python 可视化生态的事实标准。问题不在 plotnine 写得好不好,而在 matplotlib 的 Figure / Axes / Artist 模型里,没有 R 端 grid 包提供的那一层 abstract layout —— 没有可延迟解析的 Unit 表达式系统,没有 viewport 嵌套栈,没有 gtable 这种由网格化 grob 拼出整图的整体编排抽象。GoG 概念里像 panel、strip、legend 槽位、axis 边距、theme(plot.background=...) 这些,在 R + grid 模型里是一阶对象,在 matplotlib 模型里需要靠 Axes / Figure 的属性堆叠加 tight_layout / subplots_adjust 这类启发式后处理。
Bio-Babel/ggplot2-python 这个新项目选了另一条路:不在 matplotlib 之上再贴一层,而是把 R 的整条底层栈(grid / gtable / scales)一起移植到 Python,然后让 ggplot 跑在 Python 端的 grid 上。代价是工作量翻了好几倍 —— 三个上游包都得自己端口;收益是 GoG 在 Python 里第一次有了和 R 端同构的分层抽象。
本文沿着仓库 tutorials/geoms_gallery.ipynb 全部 cells 走一遍,先讲为什么 Python 还需要这个项目,再展示它在 5 个核心 geom(boxplot / violin / density / tile / hex)以及多层叠加上的 API 与 R 端 ggplot2 几乎一致。 重点是架构层面:+ 操作符、layer stack、scale 系统、coord 转换,最终如何落到 grid 上的 gtable 编排,共同构成了 GoG 在 Python 里第一次完整的 abstract layout 实现。
Grammar of Graphics 这个概念出自 Leland Wilkinson 1999 年的 The Grammar of Graphics(Springer)。Hadley Wickham 2005 年开始在 R 里把它实现成 ggplot2,2016 年的 ggplot2: Elegant Graphics for Data Analysis(Springer 2nd ed)是经典参考。GoG 的核心想法是:任何统计图形都能拆成几个正交组件 —— data、aesthetic mapping、geom、stat、coord、facet、scale、theme,它们能用 + 任意组合。
R 端 ggplot2 之所以能把 GoG 落实成一个干净可扩展的库,关键是它没有自己造图形底层,而是建在 R 的三个底层包之上:
gridnpc、cm、lines、strwidth 等可以混合算术,Unit(1, "npc") - Unit(2, "lines") 这种表达式在 viewport push 时延迟解析)、Viewport(可嵌套的视口,带局部坐标系)、Grob(图形对象树)。layout 不是函数式后处理,是一阶对象。gtablescalesggplot2 在 R 里的代码量本身不算庞大,真正繁重的工作分担到了这三个上游包。GoG 的语法之所以"干净",是因为有这套底层抽象兜底。
Python 之前缺的一直就是这套上游栈。matplotlib 不是它们的对应物 —— matplotlib 是一个完整的渲染引擎,但它的层级模型(Figure → Axes → Artist)里没有"延迟解析的 Unit 表达式"这种抽象,也没有 gtable 那种"整图 = 一张 cell 网格"的整体编排原语。
在这种基础上写一个 GoG 实现 —— plotnine(has2k1/plotnine)就是这条路 —— 完全可以让 API 对得上 R 端,且实测做得很好;但 GoG 里的某些抽象只能就近映射到 matplotlib 的相邻概念 + 一些启发式后处理。这不是 plotnine 的问题,是底层选择的问题:matplotlib 的设计目标是"通用绘图引擎",不是"R 的 grid"。
ggplot2-python 选择的工作量是把上游栈也搬过来。
仓库 pyproject.toml 的依赖列表直接给出了答案:
# pyproject.toml(节选)dependencies = [ "rgrid-python>=4.5.3", "gtable-python>=0.3.6", "scales-python>=1.4.0", "numpy>=1.24", "pandas>=1.5", "scipy>=1.10", ...]rgrid-python(import 名 grid_py)是 R grid 包的 Python 端口,提供 Unit、Viewport、GridLayout、grid_draw、grid_newpage 等核心原语,以及一个 Cairo 后端的 CairoRenderer(默认)和一个 WebRenderer(产生 SVG + Canvas + D3 的可交互 HTML)。gtable-python 是 R gtable 包的端口。scales-python 是 R scales 包的端口,统一处理 colour 映射、breaks / labels、log10 / sqrt / reverse 等 transforms。
ggplot2-python 自己的实现里不直接 import matplotlib:在仓库根 grep -ln "import matplotlib" ggplot2_py/*.py 返回为空(labeller.py 与 save.py 仅在文档字符串里提到 matplotlib mathtext / savefig 的命名习惯,不依赖)。整条渲染走的是 grid_py:
# ggplot2_py/plot.py: GGPlot._repr_png_from grid_py import grid_draw, grid_newpagegrid_newpage(width=fig_width, height=fig_height, dpi=fig_dpi)built = ggplot_build(self) # 16-stage 数据管线gtable = ggplot_gtable(built) # 编排成一张 gtablegrid_draw(gtable) # 在 viewport 栈里逐 grob 解析 Unitggplot_build 中的 16 个 stage 在BuildStage(plot.py:593-624)里命名:LAYER_DATA→SETUP_LAYER→SETUP_LAYOUT→COMPUTE_AESTHETICS→TRANSFORM_SCALES→TRAIN_POSITION→COMPUTE_STAT→MAP_STAT→COMPUTE_GEOM_1→COMPUTE_POSITION→RETRAIN_POSITION→SETUP_GUIDES→TRAIN_NONPOSITION→COMPUTE_GEOM_2→FINISH_STAT→FINISH_DATA。每一个 stage 前 / 后都能挂 hook —— R 端没有这种扩展面。
ggplot_gtable(plot_render.py:215-300)把每层 layer 调 draw_geom(...) 得到的 grob 列表交给 Layout.render(...) 排进 panel 槽,再依次把 legend(R 3.5+ 的 right / left / top / bottom / inside 五个槽位)、title / subtitle / caption / tag、plot.margin padding、plot.background 元素(以 z=-Inf 垫底)全部以 grob 的形式装进 gtable。这就是这个项目的核心:你写的 ggplot(...) + geom_xxx() + ... 表达式,在最终渲染前是一棵真正的 grid grob 树。
仓库自带 9 个 tutorial notebook,这一篇追 tutorials/geoms_gallery.ipynb 走完。它依次演示 5 个核心 geom 的多个变体加最后的层叠组合。先把环境装起来:
from ggplot2_py import *from ggplot2_py.plot import GGPlotfrom ggplot2_py.datasets import mpg, diamondsimport pandas as pdimport numpy as npmpg 是 234 行 × 11 列的车型数据(R 用户熟悉),diamonds 是 53940 行的钻石定价数据 —— 都是 ggplot2 经典示例集,通过 datasets.py 直接打包提供。
GGPlot.fig_width = 5GGPlot.fig_height = 4GGPlot.fig_dpi = 100注意这里改的是类属性,所以是全局默认 —— 后续每个 plot 出图都按这个尺寸来。R 端没有这种写法(R 里你改 device,不改 plot),但 Python 把它做成 GGPlot.fig_width 这样的类属性更顺手,Jupyter 显示时(_repr_png_)直接走这套默认值。
geom_boxplot 在 GoG 里属于一个 statistical layer:它绑定 stat_boxplot,从原始数据按 group 算 5-number summary(min / Q1 / median / Q3 / max)和 1.5 × IQR outlier 阈值,再把这些聚合值落成 box 形 grob。所以你不需要自己 pre-compute 这些值 —— aes() 给 raw 数据,stat 自动跑。
# Basic boxplotggplot(mpg, aes(x='class', y='hwy')) + geom_boxplot()
x='class' 是离散变量(7 个车型类别),y='hwy' 是连续变量(高速油耗 mpg)。x 离散,所以 stat_boxplot 按 class 分组算 summary,落到一个 panel 上 7 个 box。
加一个 fill 美学:
# Filled boxplot with colour by drive trainggplot(mpg, aes(x='class', y='hwy', fill='drv')) + geom_boxplot()
fill='drv' 让 stat 在 class × drv 二维网格上算 group。注意没有写任何 position_dodge(...),但每个 class 内不同 drv 的 box 自动并排开 —— 这是因为 geom_boxplot 的 default position 已经是 position_dodge2。GoG 的 position 是独立组件,你也可以显式覆盖。
横躺一下:
# Horizontal boxplot with coord_flipggplot(mpg, aes(x='class', y='hwy')) + geom_boxplot() + coord_flip()
coord_flip() 是 coord 子系统的一个 transform。GoG 设计里 coord 与 geom 正交,所以 boxplot、violin、histogram、scatter 都能加 coord_flip() 互换 x/y。在 ggplot2-python 实现层面这一步落在 coord.py(全文 114 KB,5 种 coord),它在 SETUP_LAYOUT 阶段挂进 Layout,后续 transform() 把 panel-local 坐标换轴。
violin 是 boxplot 的 KDE 版:同样是离散 x × 连续 y,stat_ydensity 在每个 group 内做一维 KDE,把密度估计落成对称的 violin 形 grob。
# Basic violinggplot(mpg, aes(x='class', y='hwy')) + geom_violin()
形状由数据本身的分布决定。注意每个 class 的 violin 横宽默认按"相对峰值"归一化,不直接表达样本量;如果想让宽度跟样本量挂钩,可以加 geom_violin(scale="count")。
# Filled violinggplot(mpg, aes(x='class', y='hwy', fill='class')) + geom_violin(alpha=0.6)
fill='class' 把分类变量映射到颜色;alpha=0.6 是 fixed aesthetic(geom 参数级,不参与 mapping)。GoG 区分 mapped aesthetic(写在 aes() 里,经过 scale 训练 / 映射)和 fixed aesthetic(写在 geom 参数里,所有 grob 共享同一值)—— 两者最终都落到 grob 的 Gpar(...) 字段。
# Single density curveggplot(mpg, aes(x='hwy')) + geom_density()
只指定 x 时,stat_density 做一维 KDE(默认 Gaussian kernel,bandwidth 走 nrd0)。y 由 stat 自动算出 —— 这是默认的 after_stat(density)。
# Overlapping densities by groupggplot(mpg, aes(x='hwy', fill='drv')) + geom_density(alpha=0.5)
加 fill='drv' 后,stat_density 按 drv 分三组分别 KDE,落三条带填色的密度曲线;alpha=0.5 让重叠区域可见。
# Density with colour outline onlyggplot(mpg, aes(x='hwy', colour='drv')) + geom_density(linewidth=1)
把 fill 改成 colour(GoG aesthetics 里两个独立通道:面填 vs 边描),曲线只描边、不填面。linewidth=1 是 fixed aesthetic 控制线宽。这就是 GoG 美学正交性的好处:同一份数据,切换"填面 / 描边"只是改一个映射目标,不需要换 geom。
tile 是把每个 (x, y) 对应一个矩形 cell,用 fill 通道编码 z 值 —— 也就是标准热力图。
# Simple 5x5 heatmapnp.random.seed(42)tile_data = pd.DataFrame({ 'x': np.repeat(range(5), 5), 'y': np.tile(range(5), 5), 'z': np.random.randn(25),})ggplot(tile_data, aes('x', 'y', fill='z')) + geom_tile()
默认 fill 走 scale_fill_gradient —— 蓝白(低值偏蓝,高值偏白)。这是 scales-python 提供的连续 fill scale。
# Tile with viridis colour scaleggplot(tile_data, aes('x', 'y', fill='z')) + geom_tile() + scale_fill_viridis_c()
加一个 scale_fill_viridis_c(),整张图的 fill scale 就被替换成 viridis 调色板(perceptually uniform 色系)。Scale 是独立 GoG 组件:你不修改 geom,不修改数据,只在最后 + 一个新的 scale,渲染时数据 → 颜色的映射就被替换。这是 GoG 真正"语法化"的地方,也是 scales-python 这个上游包的价值所在。
当散点图重叠太重看不清时,hex binning 是经典解法:把绘图区切成六边形 bin,用 fill 通道编码每个 bin 的点数。
# Hexagonal binningggplot(mpg, aes(x='cty', y='hwy')) + geom_hex()
stat_binhex 默认在 30 × 30 的 hex 网格上做 2D bin,默认 fill 是 count,自动接 continuous fill scale。
# Hex with fewer binsggplot(mpg, aes(x='cty', y='hwy')) + geom_hex(bins=15)
bins=15 调粗粒度。这个参数在 stat 级(传给 stat_binhex),不是 geom 级 —— ggplot2-python 自动按"stat 参数 / geom 参数 / aes 参数"分类路由进去,不需要写 stat_binhex(bins=15) + geom_hex()。
GoG 最有说服力的地方是 layer 加法。同一个 ggplot 上叠多个 layer,每层独立算 stat,最后一起进 panel:
# Boxplot + jittered points( ggplot(mpg, aes(x='class', y='hwy')) + geom_boxplot(alpha=0.3) + geom_jitter(width=0.2, size=0.8, alpha=0.5))
box 给出分布概要,jitter 露出原始点数。两层共用同一份 aes() mapping,geom_boxplot 内部走 stat_boxplot,geom_jitter 内部用 position_jitter 给原始点加扰动避免重叠。注意是 geom_jitter 而不是 geom_point() + position_jitter() —— ggplot2 给常用组合做了 convenience geom,但二者本质等价。
# Density + rug marksggplot(mpg, aes(x='hwy')) + geom_density(fill='steelblue', alpha=0.4) + geom_rug()
底部那一排短竖线是 geom_rug —— 它把每个观测的 x 投影到底边的小 tick。配合 density 曲线,你既看见分布形状,也看见样本支持的具体位置。fill='steelblue' 是 fixed aesthetic 写在 geom 参数里。
# Scatter + hex overlay( ggplot(mpg, aes(x='cty', y='hwy')) + geom_hex(alpha=0.8) + geom_point(size=0.8, alpha=0.4))
hex 提供密度,point 提供个体观测的位置感。两层都从同一份 aes() 拿 x / y,各自完成 stat,落成两个独立 grob 进入同一 panel 的 gtable cell。这种组合在大数据散点图里特别有用 —— 既保留点级别可解释性,又避免完全 overplot 看不清。
到这里 geoms_gallery.ipynb 25 个 cell 走完了。你应该能感到:每一个 plot 都是一个用 + 串起来的表达式,数据 + 美学 + geom + (optional)stat / scale / coord —— 写法和 R 端的 ggplot2 几乎逐字对应。
把 ggplot2 整套搬到 Python 之后,作者还顺手加了一些 R 没有的 Python 习惯写法。这些不修改 GoG 本身,只是扩展机制:
aes()aes(y=lambda d: np.log(d["mpg"])) —— 直接在 mapping 里嵌一个 lambda,不需要先在 dataframe 里 pre-compute 一列。after_stat() / after_scale() 同样支持 callable,可以在 stat 算完 / scale 映射完之后再插一个表达式,例如 aes(y=after_stat(lambda d: d["count"] / d["count"].sum())) 把直方图 count 归一化为比例。p.add_build_hook("after", BuildStage.COMPUTE_STAT, fn) —— 在 16 个具名阶段里挑一个,前 / 后挂回调。给做 ggplot 二次开发(自定义 stat、调试 pipeline)的人准备的扩展面。@update_ggplot.register(MyClass)functools.singledispatch,任何 Python 类都能注册成 + 操作符的合法右值。ggplot_build 自身也是 singledispatch,扩展包能整体替换 build pipeline。@runtime_checkable Protocolsggplot2_py/protocols.py 给 Geom / Stat / Scale / Coord / Facet / Position 各定义了一个 Protocol,可以 isinstance(my_geom, GeomProtocol) 在运行时检结构是否符合契约。R 那边没有这套机制。__init_subclass__class GeomStar(Geom): ... 写完不需要再手动调注册函数,Python 元编程自动接进 ggplot 的 geom 注册表。with ggplot_defaults(theme=theme_minimal()): ... —— 用 contextvars.ContextVar 实现的作用域默认,出 with 块就还原。比 R 端 theme_set() 全局污染的写法更安全。这些是项目的"+1"。GoG 本身不需要它们,但它们让 ggplot2-python 在 Python 生态里更像 Python。
回到开头那个问题:Python 已经有 plotnine,我们是否还需要一个 ggplot2-python?
如果你只是要在 Python 里画 GoG 风格图,plotnine 一直够用,这两年它的成熟度也不低 —— 答案是"不一定"。但如果你的工作经常在 R 与 Python 之间穿梭,或者要做 ggplot 的二次开发(自定义 stat / theme、改 build pipeline、写扩展包),ggplot2-python 提供的"对齐 R 上游栈"是有意义的:
<- 为 = 加 + ggplot() 链式调用grid / gtable / scales 的能力,在 Python 端能用同名概念调到aes / after_stat / after_scale 让 Python 习惯写法直接进 GoG代价是对 grid / gtable / scales 的依赖更深,这是一个明显"重栈"的选择 —— 工作量大,但抽象边界清晰。Python 用户需不需要这条线,取决于你的工作流离 R 有多近。
代码: https://github.com/Bio-Babel/ggplot2-python;
本文示例来源的教程: https://github.com/Bio-Babel/ggplot2-python/tree/main/tutorials;
依赖: rgrid-python(R grid 端口) / gtable-python / scales-python;

如何联系我们


已有生信基地联系方式的同学无需重复添加

