
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 在 R 里为什么成立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 的三个底层包之上:grid(R core 自带):一个抽象的二维布局系统。它提供 Unit(单位表达式:npc、cm、lines、strwidth等可以混合算术,Unit(1, npc) - Unit(2, lines)这种表达式在 viewport push 时延迟解析)、Viewport(可嵌套的视口,带局部坐标系)、Grob(图形对象树)。layout 不是函数式后处理,是一阶对象。gtable(Wickham 维护):基于 grid 的网格表格布局,把 grob 装进 row × col 的 cell。一个 ggplot 的最终产物本质上是一张 gtable —— title / subtitle 行、strip / panel / axis 行、legend 列、margin —— 全是 gtable cell。scales(Wickham 维护):统一抽象数据空间 ↔ 美学空间(色板、形状、大小)的训练 / 映射 / 反向映射,提供 breaks / labels / log10 / sqrt / reverse 等 transforms。ggplot2 在 R 里的代码量本身不算庞大,真正繁重的工作分担到了这三个上游包。GoG 的语法之所以干净,是因为有这套底层抽象兜底。Python 这边过去缺的是什么Python 之前缺的一直就是这套上游栈。matplotlib 不是它们的对应物 —— matplotlib 是一个完整的渲染引擎,但它的层级模型(Figure → Axes → Artist)里没有延迟解析的 Unit 表达式这种抽象,也没有 gtable 那种整图 一张 cell 网格的整体编排原语。在这种基础上写一个 GoG 实现 ——plotnine(has2k1/plotnine)就是这条路 —— 完全可以让 API 对得上 R 端,且实测做得很好;但 GoG 里的某些抽象只能就近映射到 matplotlib 的相邻概念 一些启发式后处理。这不是 plotnine 的问题,是底层选择的问题:matplotlib 的设计目标是通用绘图引擎,不是R 的 grid。ggplot2-python 选择的工作量是把上游栈也搬过来。ggplot2-python 怎么解这个问题仓库pyproject.toml的依赖列表直接给出了答案:# pyproject.toml(节选)dependencies[rgrid-python4.5.3,gtable-python0.3.6,scales-python1.4.0,numpy1.24,pandas1.5,scipy1.10,...]rgrid-python(import 名grid_py)是 Rgrid包的 Python 端口,提供Unit、Viewport、GridLayout、grid_draw、grid_newpage等核心原语,以及一个 Cairo 后端的CairoRenderer(默认)和一个WebRenderer(产生 SVG Canvas D3 的可交互 HTML)。gtable-python是 Rgtable包的端口。scales-python是 Rscales包的端口,统一处理 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_pyimportgrid_draw, grid_newpage grid_newpage(widthfig_width,heightfig_height,dpifig_dpi)builtggplot_build(self)# 16-stage 数据管线gtableggplot_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.marginpadding、plot.background元素(以z-Inf垫底)全部以 grob 的形式装进 gtable。这就是这个项目的核心:你写的ggplot(...) geom_xxx() ...表达式,在最终渲染前是一棵真正的 grid grob 树。上手:geoms_gallery 全程仓库自带 9 个 tutorial notebook,这一篇追tutorials/geoms_gallery.ipynb走完。它依次演示 5 个核心 geom 的多个变体加最后的层叠组合。先把环境装起来:from ggplot2_pyimport* from ggplot2_py.plotimportGGPlot from ggplot2_py.datasetsimportmpg, diamondsimportpandas as pdimportnumpy as npmpg是 234 行 × 11 列的车型数据(R 用户熟悉),diamonds是 53940 行的钻石定价数据 —— 都是 ggplot2 经典示例集,通过datasets.py直接打包提供。GGPlot.fig_width5GGPlot.fig_height4GGPlot.fig_dpi100注意这里改的是类属性,所以是全局默认 —— 后续每个 plot 出图都按这个尺寸来。R 端没有这种写法(R 里你改 device,不改 plot),但 Python 把它做成GGPlot.fig_width这样的类属性更顺手,Jupyter 显示时(_repr_png_)直接走这套默认值。geom_boxplotgeom_boxplot在 GoG 里属于一个 statistical layer:它绑定stat_boxplot,从原始数据按 group 算 5-number summary(min / Q1 / median / Q3 / max)和1.5 × IQRoutlier 阈值,再把这些聚合值落成 box 形 grob。所以你不需要自己 pre-compute 这些值 ——aes()给 raw 数据,stat 自动跑。# Basic boxplotggplot(mpg, aes(xclass,yhwy)) geom_boxplot()xclass是离散变量(7 个车型类别),yhwy是连续变量(高速油耗 mpg)。x 离散,所以stat_boxplot按 class 分组算 summary,落到一个 panel 上 7 个 box。加一个fill美学:# Filled boxplot with colour by drive trainggplot(mpg, aes(xclass,yhwy,filldrv)) geom_boxplot()filldrv让 stat 在 class × drv 二维网格上算 group。注意没有写任何position_dodge(...),但每个 class 内不同 drv 的 box 自动并排开 —— 这是因为geom_boxplot的 default position 已经是position_dodge2。GoG 的 position 是独立组件,你也可以显式覆盖。横躺一下:# Horizontal boxplot with coord_flipggplot(mpg, aes(xclass,yhwy)) 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 坐标换轴。geom_violinviolin 是 boxplot 的 KDE 版:同样是离散 x × 连续 y,stat_ydensity在每个 group 内做一维 KDE,把密度估计落成对称的 violin 形 grob。# Basic violinggplot(mpg, aes(xclass,yhwy)) geom_violin()形状由数据本身的分布决定。注意每个 class 的 violin 横宽默认按相对峰值归一化,不直接表达样本量;如果想让宽度跟样本量挂钩,可以加geom_violin(scalecount)。# Filled violinggplot(mpg, aes(xclass,yhwy,fillclass)) geom_violin(alpha0.6)fillclass把分类变量映射到颜色;alpha0.6是 fixed aesthetic(geom 参数级,不参与 mapping)。GoG 区分 mapped aesthetic(写在aes()里,经过 scale 训练 / 映射)和 fixed aesthetic(写在 geom 参数里,所有 grob 共享同一值)—— 两者最终都落到 grob 的Gpar(...)字段。geom_density# Single density curveggplot(mpg, aes(xhwy)) geom_density()只指定x时,stat_density做一维 KDE(默认 Gaussian kernel,bandwidth 走 nrd0)。y 由 stat 自动算出 —— 这是默认的after_stat(density)。# Overlapping densities by groupggplot(mpg, aes(xhwy,filldrv)) geom_density(alpha0.5)加filldrv后,stat_density 按 drv 分三组分别 KDE,落三条带填色的密度曲线;alpha0.5让重叠区域可见。# Density with colour outline onlyggplot(mpg, aes(xhwy,colourdrv)) geom_density(linewidth1)把fill改成colour(GoG aesthetics 里两个独立通道:面填 vs 边描),曲线只描边、不填面。linewidth1是 fixed aesthetic 控制线宽。这就是 GoG 美学正交性的好处:同一份数据,切换填面 / 描边只是改一个映射目标,不需要换 geom。geom_tiletile 是把每个 (x, y) 对应一个矩形 cell,用 fill 通道编码 z 值 —— 也就是标准热力图。# Simple 5x5 heatmapnp.random.seed(42)tile_datapd.DataFrame({x:np.repeat(range(5),5),y:np.tile(range(5),5),z:np.random.randn(25),})ggplot(tile_data, aes(x,y,fillz)) geom_tile()默认 fill 走scale_fill_gradient—— 蓝白(低值偏蓝,高值偏白)。这是 scales-python 提供的连续 fill scale。# Tile with viridis colour scaleggplot(tile_data, aes(x,y,fillz)) geom_tile() scale_fill_viridis_c()加一个scale_fill_viridis_c(),整张图的 fill scale 就被替换成 viridis 调色板(perceptually uniform 色系)。Scale 是独立 GoG 组件:你不修改 geom,不修改数据,只在最后一个新的 scale,渲染时数据 → 颜色的映射就被替换。这是 GoG 真正语法化的地方,也是 scales-python 这个上游包的价值所在。geom_hex当散点图重叠太重看不清时,hex binning 是经典解法:把绘图区切成六边形 bin,用 fill 通道编码每个 bin 的点数。# Hexagonal binningggplot(mpg, aes(xcty,yhwy)) geom_hex()stat_binhex默认在 30 × 30 的 hex 网格上做 2D bin,默认 fill 是count,自动接 continuous fill scale。# Hex with fewer binsggplot(mpg, aes(xcty,yhwy)) geom_hex(bins15)bins15调粗粒度。这个参数在 stat 级(传给stat_binhex),不是 geom 级 —— ggplot2-python 自动按stat 参数 / geom 参数 / aes 参数分类路由进去,不需要写stat_binhex(bins15) geom_hex()。Combining geomsGoG 最有说服力的地方是layer 加法。同一个 ggplot 上叠多个 layer,每层独立算 stat,最后一起进 panel:# Boxplot jittered points(ggplot(mpg, aes(xclass,yhwy)) geom_boxplot(alpha0.3) geom_jitter(width0.2,size0.8,alpha0.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(xhwy)) geom_density(fillsteelblue,alpha0.4) geom_rug()底部那一排短竖线是geom_rug—— 它把每个观测的 x 投影到底边的小 tick。配合 density 曲线,你既看见分布形状,也看见样本支持的具体位置。fillsteelblue是 fixed aesthetic 写在 geom 参数里。# Scatter hex overlay(ggplot(mpg, aes(xcty,yhwy)) geom_hex(alpha0.8) geom_point(size0.8,alpha0.4))hex 提供密度,point 提供个体观测的位置感。两层都从同一份aes()拿 x / y,各自完成 stat,落成两个独立 grob 进入同一 panel 的 gtable cell。这种组合在大数据散点图里特别有用 —— 既保留点级别可解释性,又避免完全 overplot 看不清。到这里geoms_gallery.ipynb25 个 cell 走完了。你应该能感到:每一个 plot 都是一个用串起来的表达式,数据 美学 geom (optional)stat / scale / coord —— 写法和 R 端的 ggplot2 几乎逐字对应。Python-exclusive 扩展点把 ggplot2 整套搬到 Python 之后,作者还顺手加了一些 R 没有的 Python 习惯写法。这些不修改 GoG 本身,只是扩展机制:Callableaes():aes(ylambda d: np.log(d[mpg]))—— 直接在 mapping 里嵌一个 lambda,不需要先在 dataframe 里 pre-compute 一列。after_stat()/after_scale()同样支持 callable,可以在 stat 算完 / scale 映射完之后再插一个表达式,例如aes(yafter_stat(lambda d: d[count] / d[count].sum()))把直方图 count 归一化为比例。Build hooks 16 stages: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_checkableProtocols:ggplot2_py/protocols.py给 Geom / Stat / Scale / Coord / Facet / Position 各定义了一个 Protocol,可以isinstance(my_geom, GeomProtocol)在运行时检结构是否符合契约。R 那边没有这套机制。Auto-registration via__init_subclass__:class GeomStar(Geom): ...写完不需要再手动调注册函数,Python 元编程自动接进 ggplot 的 geom 注册表。Scoped defaults:with ggplot_defaults(themetheme_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 上游栈是有意义的:一份 R ggplot 代码翻成 Python,几乎是逐行替换-为加 ggplot()链式调用R 那边grid/gtable/scales的能力,在 Python 端能用同名概念调到16-stage build hooks 是 R 端没有的扩展面;callableaes/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;