跳到正文

R 表格生成:knitr::kable 与 gt 包实战

报告里放一张表,先要判断它值多少工夫。控制台上扫一眼就能得到的信息,不值得写排版代码;要跟着报告反复重跑、被正文引用的表,值得;交给期刊或不做分析的合作者、他们会直接在 Word 里改的表,最值得。这一篇按这个顺序讲三档做法:kable() 起步,kableExtra 补细节,gt 做交付。

knitr::kable() 是 knitr 自带的函数,不用额外装包。它会根据当前输出格式自动换样式:HTML 出 <table>,PDF 出 LaTeX 三线表,Word 出 Pandoc 的管道表。

knitr::kable(head(mtcars))
| | mpg| cyl| disp| hp| drat| wt| qsec| vs| am| gear| carb|
|:-----------------|----:|---:|----:|---:|----:|-----:|-----:|--:|--:|----:|----:|
|Mazda RX4 | 21.0| 6| 160| 110| 3.90| 2.62| 16.46| 0| 1| 4| 4|
|Mazda RX4 Wag | 21.0| 6| 160| 110| 3.90| 2.875| 17.02| 0| 1| 4| 4|
|Datsun 710 | 22.8| 4| 108| 93| 3.85| 2.32| 18.61| 1| 1| 4| 1|
|Hornet 4 Drive | 21.4| 6| 258| 110| 3.08| 3.215| 19.44| 1| 0| 3| 1|
|Hornet Sportabout | 18.7| 8| 360| 175| 3.15| 3.44| 17.02| 0| 0| 3| 2|
|Valiant | 18.1| 6| 225| 105| 2.76| 3.46| 20.22| 1| 0| 3| 1|

它在控制台里长这样,和直接 print(head(mtcars)) 的结果摆在一起看,差别很清楚:

mpg cyl disp hp drat wt qsec vs am gear carb
Mazda RX4 21.0 6 160 110 3.90 2.620 16.46 0 1 4 4
Mazda RX4 Wag 21.0 6 160 110 3.90 2.875 17.02 0 1 4 4
Datsun 710 22.8 4 108 93 3.85 2.320 18.61 1 1 4 1
Hornet 4 Drive 21.4 6 258 110 3.08 3.215 19.44 1 0 3 1
Hornet Sportabout 18.7 8 360 175 3.15 3.440 17.02 0 0 3 2
Valiant 18.1 6 225 105 2.76 3.460 20.22 1 0 3 1

同样六行数据,print() 的 wt 列是 2.620,kable 是 2.62——前者给整列补上相同的小数位,后者只保留该有的精度。更重要的差别是 kable 输出的是结构化标记:宽度交给渲染端算,表格能自动适应页面,数字也不会被补零。

顺带一个写论文的细节:小数位不要设得比数据本身的精度还高。仪器读数到小数点后两位,表里写三位就是虚假精度,审稿人偶尔会揪这一点;反过来,样本量、计数这类整数也不该被补成 12.0。经验做法是均值保留的位数与原始测量一致,标准差可以多留一位。

常用参数只有几个,够覆盖大部分场合:

  • caption:表标题,在 PDF 里由 LaTeX 自动编号。
  • digits:保留几位小数,默认跟随 getOption("digits")
  • col.names:改列名。把英文列名换成中文是最常见的用法,注意管道表(pipe 格式)靠空格对齐,中文列名会让宽度算不准,正式输出走 HTML 或 LaTeX 就没这个问题。
  • align:由 "l""c""r" 组成的字符串,长度等于列数。
  • format"pipe""html""latex",一般不用写,kable 跟着当前输出格式走;只有要手工检查 LaTeX 源码时才显式指定。

一份实际写进报告的表:

data(ToothGrowth)
agg <- aggregate(len ~ supp + dose, data = ToothGrowth, FUN = mean)
knitr::kable(agg, digits = 2, caption = "不同给药方式与剂量下的齿生长均值")
|supp | dose| len|
|:----|----:|-----:|
|OJ | 0.5| 13.23|
|VC | 0.5| 7.98|
|OJ | 1.0| 22.70|
|VC | 1.0| 16.77|
|OJ | 2.0| 26.06|
|VC | 2.0| 26.14|

aggregate() 的公式写法是 数值 ~ 分组变量,这里是按给药方式(supp)和剂量(dose)两个变量分组求 len 的均值。

kable 只管把数据变成表,样式细节交给 kableExtra:

library(kableExtra)
agg |>
kable(digits = 2, caption = "齿生长均值") |>
kable_styling(latex_options = c("striped", "hold_position")) |>
add_header_above(c(" " = 2, "齿长 (cm)" = 1))

三个函数各管一段:kable_styling() 控制整体样式,斑马纹、字号、表格宽度、水平位置都在这里;add_header_above() 加一层合并表头,上面这行的意思是前两列留空、第三列跨列表头写「齿长 (cm)」;pack_rows() 按某个变量把行分组,column_spec() 单独调某一列的宽度或背景色。

用 kableExtra 有一半时间花在 LaTeX 的位置上。LaTeX 默认把表格当浮动体,会自动挪到页面顶部或下一页,结果是「表 1」出现在正文提到它的三段之后。hold_position 就是把表格钉在代码块的位置。另一个常见的坑是:kableExtra 的样式要按输出格式分别调,同一份代码在 HTML 和 PDF 下的观感差别不小,别指望一套参数两边都好看。

gt 走的是另一条设计路线:表格对象可以被一串函数逐层加工,每一步只改一件事。

library(gt)
agg |>
gt() |>
tab_header(
title = "齿生长长度均值",
subtitle = "ToothGrowth:按给药方式与剂量分组"
) |>
cols_label(supp = "给药方式", dose = "剂量 (mg)", len = "平均长度") |>
fmt_number(columns = "len", decimals = 2) |>
cols_align(align = "center", columns = c("supp", "dose"))

gt() 建表,tab_header() 加标题和副标题,cols_label() 换列名,fmt_number() 统一数字格式,cols_align() 调整对齐。这套改法的好处是回头改哪一项都只动一行,不必像 kableExtra 那样把参数全塞进一个函数调用里。数字格式化是 gt 的强项:除 fmt_number(),还有 fmt_percent()fmt_currency()fmt_scientific()fmt_missing() 处理缺失值显示。

在 RStudio 里执行这段代码,结果出现在 Viewer 面板;写进 Rmd 的 HTML 输出就是网页表格,PDF 输出则由 gt 转成 LaTeX。

需要单独交一张表时用 gtsave(),它按扩展名判断格式:

tab <- agg |>
gt() |>
tab_header(title = "齿生长长度均值")
gtsave(tab, "output/growth-table.docx")

.docx 得到的是 Word 原生表格,合作者打开就能改字号、加批注;.html 得到单个自包含的网页文件;.png.pdf 需要额外安装 webshot2(背后是无头 Chrome),机器上没浏览器会报错。想手工检查转换结果,可以用 as_latex() 看 gt 生成的 LaTeX 代码。

标题这件事在 R Markdown 和 Quarto 里处理方式不同,值得单独说清楚。

R Markdown 下,kable(caption = ...) 在 PDF 输出里由 LaTeX 自动编号,正文里想引用「见表 1」只能手写;HTML 输出连编号都没有,只有一段表格说明文字。要在 R Markdown 里做自动编号加交叉引用,得引入 bookdown 的 @ref(tab:...) 语法,还要在 caption 里写标记——这也是不少人最后转向 Quarto 的直接原因。

Quarto 把这件事变成了一等公民:块里写 #| label: tbl-agg#| tbl-cap: "不同给药方式与剂量下的齿生长均值",正文里写 @tbl-agg,编号、跳转、排序全部自动。有一个容易踩的地方——如果同时给 kable() 传了 caption,页面上会出现两个标题,用了 tbl-cap 就把 kable 的 caption 留空。完整写法见 /r/reporting/quarto/

  • 只有两三个数字:写进句子里,读者一眼扫过,比让他在表里对行对列快得多。
  • 列数超过十列:拆成两张表,或者把次要列挪进附录,别用缩小字号硬塞。
  • 表格只给同事看一眼:print() 或 RStudio 的 View(),不要为它写 kable。
  • 交付给期刊:先确认对方要 Word 表格还是 LaTeX 源码,gt 的 gtsave() 两条路都能走,但排出来的效果要按对方模板再对一遍。

表格的数字从哪来、半年后还能不能重跑出同样一张表,是比排版更靠前的问题,接着看 /r/reporting/reproducible/