← 返回 Tessera
表格5 张图gtsummary::trial(200 行 × 8 列,模拟临床试验)2026-08-04

tableone

两组人在基线上到底可不可比?

需要的输入
一份个体级数据 —— 每行一个人,若干列变量,外加一个分组列
示例数据
gtsummary::trial(200 行 × 8 列,模拟临床试验)
依赖
gtsummary · gt · dplyr · ukbflow
配色
未命名#F8E8E7 #FFFFFF
成图预览另有 4 张在配方里
tableone

什么时候用它

论文里的第一张表,几乎总是它:把人按组分开,逐个变量报一遍基线。它回答的问题只有一个——这两组人在开始的时候可不可比。

它和这一栏别的条目不一样:表格不是图。但它受的约束是同一套——投稿要的格式、底纹配色、导出,以及"代码改了图得跟着变"。所以它在这儿。

怎么读

  1. 先看表头的 N。 后面所有百分比的分母都在那儿。
  2. 看脚注里的统计量约定。 46 (37, 60) 是中位数还是均值,只有脚注说了算。
  3. 看有没有 Unknown 行。 没有不代表没缺失,可能只是没让它显示——见下。

常见陷阱

missing = "no"tbl_summary() 的默认值。 于是缺失被默默藏起来了:marker 有 10 个 NA,默认那张表上一个字都不提,百分比却已经按去掉 NA 的分母算好了。想让它出现要显式写 missing = "ifany"默认值把"我没有缺失"和"我不想显示缺失"混成了同一个样子。

百分比的分母有三种。 percent = "column"(默认)是每组内部占比,"row" 是这一行里各组的构成,"cell" 是占总体。选错了整列数字都是错的,而表面上完全看不出来——它们都是形如 35 (36%) 的合法数字。

统计量由分布决定,不是由习惯决定。 偏态变量用中位数 (IQR),近似正态才用均值 (SD)。同一张表里两种混用是可以的,但必须在脚注里说清楚哪个是哪个

RCT 的 Table 1 里,p 值没有信息量。 随机化本身就保证了基线差异来自偶然,检验一个已知为真的原假设得不到任何东西,CONSORT 也不建议。要衡量组间不平衡的程度就用 SMD。观察性研究不同,那里 p 值是站得住的。(这一页的示例数据两者结论一致,看不出分别——真要看出差别得是小样本大效应的场景。)

gtsummary 2.0 改过一批 API。 modify_footnote_header()remove_footnote_header() 这些都是新名字,拿旧版跑直接报错。所以这一条的运行环境记着版本号,翻到页尾能看。

配色

表格的"配色"就是底纹,而且默认答案是不上色:三线表、黑字白底,这是绝大多数期刊排版的样子,也最不容易在转格式时出问题。

底色只在一种情况下值得加:表很长、变量很多,读者需要横向对齐一行。这时按变量分块交替上一层极浅的底(这里用 #F8E8E7),起的是斑马线的作用。要按变量分块,不是按行号——一个变量占 1~N 行(标签行加各个水平),按行号会把同一个变量从中间劈开。

配方

方法绘图系统什么时候选它
Atbl_summary() + gtR · gtsummary + gt要非标准的脚注、底纹、期刊特定排版 —— 每一处都能改
Bukbflow::plot_tableone()R · gtsummary 封装常规 Table 1,一行出结果
Ctbl_merge() / tbl_stack()R · gtsummary两张表要横着拼或竖着摞

一、画出来

library(gtsummary)
library(gt)
library(dplyr)

data(trial)

tbl <- trial |>
  select(trt, age, grade, marker) |>
  tbl_summary(
    by        = trt,
    label     = list(age    ~ "Age (years)",
                     grade  ~ "Tumor grade",
                     marker ~ "Marker level (ng/mL)"),
    statistic = list(all_continuous()  ~ "{median} ({p25}, {p75})",
                     all_categorical() ~ "{n} ({p}%)"),
    digits    = all_continuous() ~ 1,
    missing   = "ifany"   # 默认是 "no",缺失会被藏起来
  ) |>
  add_p() |>
  bold_labels()

n_row <- nrow(tbl$table_body)

三线表要做两件事,只做一半是不够的:加三条线,再把其余横线全关掉。少了后一半,变量之间的灰色细线还在,那是 gt 的默认长相,不是三线表。

顶线那一条有个讲究:tab_options(table.border.top.*),不要用 tab_style(cells_column_labels())。后者把线画在表头单元格上,普通表看不出区别,但等你去拼表(下面第三节),表头会多出一行 spanner,那条"顶"线就跑到了 spanner 下面——而且因为第一列没有 spanner,它还只画一半。table.border.top 是整张表的边,表头有几行都不影响它。

#| fig: threeline
#| out: gt
strip_rules <- function(g) {
  tab_options(
    g,
    table.border.top.style            = "none",
    table.border.bottom.style         = "none",
    heading.border.bottom.style       = "none",
    column_labels.border.top.style    = "none",
    column_labels.border.bottom.style = "none",
    table_body.border.top.style       = "none",
    table_body.border.bottom.style    = "none",
    table_body.hlines.style           = "none"
  )
}

three_line <- function(g, n) {
  g |>
    # 顶线用 table.border.top 画,别挂到 cells_column_labels() 上 —— 见下
    tab_options(table.border.top.style = "solid",
                table.border.top.width = px(3),
                table.border.top.color = "black") |>
    tab_style(cell_borders("bottom", color = "black", weight = px(2)),
              cells_column_labels()) |>
    tab_style(cell_borders("bottom", color = "black", weight = px(3)),
              cells_body(rows = n))
}

as_gt(tbl) |>
  strip_rules() |>
  three_line(n_row)
tableone — threeline
tableone-threeline

底色版。block_rows() 是这一段唯一有内容的函数:它按 row_type == "label" 找出每个变量的起点,把同一个变量的所有行归成一块。

#| fig: shaded
#| out: gt
block_rows <- function(body) {
  lab <- which(body$row_type == "label")
  rep(seq_along(lab), times = diff(c(lab, nrow(body) + 1L)))
}

shaded <- which(block_rows(tbl$table_body) %% 2L == 1L)

as_gt(tbl) |>
  strip_rules() |>
  tab_style(cell_fill("#F8E8E7"), cells_body(rows = shaded)) |>
  tab_style(cell_fill("#F8E8E7"), cells_column_labels()) |>
  three_line(n_row)
tableone — shaded
tableone-shaded

二、一行版

上面那四十行如果每张表都要重写一遍,迟早会写歪。ukbflowplot_tableone() 把它包成了一个函数:三线表、分块底色、p 值格式、列宽、四种格式导出全在里面。

#| fig: wrapped
#| out: gt
library(ukbflow)

plot_tableone(
  data   = trial,
  vars   = c("age", "grade", "marker"),
  strata = "trt",
  label  = list(age    ~ "Age (years)",
                grade  ~ "Tumor grade",
                marker ~ "Marker level (ng/mL)"),
  theme  = "lancet",
  save   = FALSE
)
tableone — wrapped
tableone-wrapped

它比手写版多做了几件事:p 值统一成三位小数、表头写成斜体 P-value、各列给了固定像素宽度。换来的代价是只能改暴露出来的那些参数——变量级的脚注("分期按 AJCC 第 8 版"这种)参数表里没有,要加还得回到手写。

三、拼两张表

同一批人换个分组方式看,用 tbl_merge() 横着拼。

#| fig: merge
#| out: gt
by_trt <- trial |>
  select(trt, age, grade) |>
  tbl_summary(by = trt, missing = "no")

by_resp <- trial |>
  filter(!is.na(response)) |>
  select(response, age, grade) |>
  tbl_summary(by = response, missing = "no")

merged <- tbl_merge(
  list(by_trt, by_resp),
  tab_spanner = c("**按治疗分组**", "**按缓解分组**")
)

# three_line() 不用改 —— 顶线走的是 table.border.top,
# 多一行 spanner 也照样画在整张表最上面
as_gt(merged) |>
  strip_rules() |>
  three_line(nrow(merged$table_body))
tableone — merge
tableone-merge

不同的人按同一套变量报,用 tbl_stack() 竖着摞。这里有个坑:摞起来之后表头只有一个,而它取的是第一张表的 N——下半块的 N 其实不一样,表头却还写着上半块的数。gtsummary 只发一条 message,图上完全看不出来。所以摞完要把 N 从表头拿掉。

#| fig: stack
#| out: gt
grade_i <- trial |>
  filter(grade == "I") |>
  select(trt, age, marker) |>
  tbl_summary(by = trt, missing = "no")

grade_iii <- trial |>
  filter(grade == "III") |>
  select(trt, age, marker) |>
  tbl_summary(by = trt, missing = "no")

stacked <- tbl_stack(
  list(grade_i, grade_iii),
  group_header = c("Grade I", "Grade III"),
  quiet        = TRUE
) |>
  modify_header(all_stat_cols() ~ "**{level}**")   # N 对下半块是错的,去掉

as_gt(stacked) |>
  strip_rules() |>
  three_line(nrow(stacked$table_body))
tableone — stack
tableone-stack

四、存成文件

按负担从小到大排:

目标 写法 结果
HTML gtsave(g, "t1.html") 直接可用
PNG gtsave(g, "t1.png") 直接可用,已自动裁到表格,不用管留白
PDF gtsave(g, "t1.pdf") 底色会掉,还占满整张 A4
PDF pagedown::chrome_print() 底色在,纸张尺寸能控

PDF 那条是唯一要绕的,原因在 gtsave() 内部:PNG 和 PDF 走的是同一个函数gt_save_webshot()),但 PNG 是截图、PDF 是 Chrome 打印,而打印时 printBackground 默认关着。所以不是 gt 的样式没生成,是浏览器打印时把底色扔了。

#| eval: false
# HTML / PNG —— 零负担
gtsave(g, "table1.html")
gtsave(g, "table1.png")

# PDF —— 绕开 gtsave 的打印通道,自己调 chrome_print
tmp <- tempfile(fileext = ".html")
gtsave(g, tmp)
pagedown::chrome_print(
  tmp,
  output  = "table1.pdf",
  format  = "pdf",
  options = list(paperWidth = 8, paperHeight = 5)   # 按内容定纸张,别用 A4
)

Word 单独说。 as_flex_table() 转出来的 docx 是没有样式的——所有 tab_style() 都加在 gt 对象上,而 flextable 是从 gtsummary 对象转的,它根本没见过那些样式。gtsave(g, "t1.docx") 倒是把底纹写进了 XML,但排版仍然是 gt 自己那一套。

实际交付里更可靠的路子是先出 PDF,再用 Foxit 之类的工具转 Word:拿到的是所见即所得的版式,编辑部那边也能直接改。多绕一步,但省掉了在 Word 里重排一遍。

三种做法怎么选

  1. 常规 Table 1 → plot_tableone() 一行,样式和导出都定好了。
  2. 有期刊特定要求 → 手写 tbl_summary() + gt 变量级脚注、caption、非标准的分块规则,这些封装的参数表里没有。
  3. 要对比两种分组或两个亚组 → tbl_merge() / tbl_stack() 前提是三线表的顶线一开始就写对(用 table.border.top),否则拼表会把它顶歪;stack 之后还要记得把表头的 N 去掉。
运行环境5 个包 · 2026-08-04

R version 4.5.1 (2025-06-13 ucrt) · x86_64-w64-mingw32

  • dplyr 1.2.1
  • gt 1.0.0
  • gtsummary 2.4.0
  • pagedown 0.23
  • ukbflow 0.3.4