Folioevanzhou.org
← 返回 Tessera
R5 张图2026-08-04

tableone

A baseline table that reports every variable once per group, ruled as a three-line table for submission.

示例数据
lung_survival
配色
heat_light
语言
R
成图预览另有 4 张在配方里
tableone

Introduction

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

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

读的时候按这个顺序:

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

Example Data

Table 1 要的输入是:一份个体级数据——每行一个人,若干列变量,外加一个分组列。它不需要事先汇总;分组、统计量和百分比都是 tbl_summary() 现算的,先手动聚合一遍反而把原始分布丢了。

这里使用 lung_survival 的 228 位患者,按 sex 分组,汇总年龄、ECOG 评分和随访时间。它同时覆盖连续变量、分类变量和缺失值。

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

table_data <- read.csv(
  "https://assets.evanzhou.org/tessera/csv/lung_survival.csv"
) |>
  mutate(
    sex = factor(sex, levels = c("male", "female"), labels = c("Male", "Female")),
    event = factor(event, levels = c(0, 1), labels = c("Censored", "Death")),
    ph_ecog = factor(ph_ecog)
  )

table_data |>
  select(sex, age_years, ph_ecog, time_days) |>
  summarise(across(everything(), ~ sum(is.na(.x))))
#>   sex age_years ph_ecog time_days
#> 1   0         0       1         0

# ph_ecog 有 1 个缺失,而 tbl_summary() 默认一个字都不提 —— 见 Constraints

Palettes

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

底色只在一种情况下值得加:表很长、变量很多,读者需要横向对齐一行。这时按变量分块交替上一层极浅的底,起的是斑马线的作用。

要按变量分块,不是按行号——一个变量占 1~N 行(标签行加各个水平),按行号会把同一个变量从中间劈开,斑马线就不再对应任何东西。

# 从 heat_light 的蓝色向白色混合出浅蓝底纹,只用来分块
SHADE <- colorRampPalette(
  c("#FFFFFF", get_palette("heat_light", type = "qualitative")[[2]])
)(5)[[2]]

Recipe

No. Method Input Data Palettes
1 gtsummary + gt table_data SHADE
2 ukbflow table_data —
3 gtsummary table_data —

1 · gtsummary + gt

tbl_summary() 出内容,gt 出版式。两件事是分开的,所以下面的线和底纹都挂在 gt 对象上,和统计量怎么算无关。

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

tbl <- table_data |>
  select(sex, age_years, ph_ecog, time_days) |>
  tbl_summary(
    by        = sex,
    label     = list(age_years ~ "Age (years)",
                     ph_ecog   ~ "ECOG performance score",
                     time_days ~ "Follow-up time (days)"),
    # 连续变量一律中位数 (IQR):这几个变量都不保证近似正态,
    # 而一旦混用均值和中位数,就必须在脚注里逐个交代哪个是哪个
    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())。后者把线画在表头单元格上,普通表看不出区别,但等你去拼表(recipe 3),表头会多出一行 spanner,那条"顶"线就跑到了 spanner 下面——而且因为第一列没有 spanner,它还只画一半。table.border.top 是整张表的边,表头有几行都不影响它。

#| fig: threeline
#| out: gt
strip_rules <- function(g) {
  # 关掉 gt 的全部默认横线。少关一处,三线表里就多一条灰线,
  # 而它在浅色背景上几乎看不出来
  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 |>
    # 顶线走整张表的边,不挂到 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()) |>
    # 底线画在最后一行的下边框上,所以 n 要是 table_body 的行数,不是数据的行数
    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() 是这一段唯一有内容的函数。

#| fig: shaded
#| out: gt
# 按 row_type == "label" 找出每个变量的起点,再把标签行和它底下的各个水平
# 归成同一块 —— 这样交替上色才是"一个变量一条斑马线"
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(SHADE), cells_body(rows = shaded)) |>
  tab_style(cell_fill(SHADE), cells_column_labels()) |>
  three_line(n_row)
tableone — shaded
tableone-shaded

2 · ukbflow

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

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

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

plot_tableone(
  data   = table_data,
  vars   = c("age_years", "ph_ecog", "time_days"),
  strata = "sex",
  label  = list(age_years ~ "Age (years)",
                ph_ecog   ~ "ECOG performance score",
                time_days ~ "Follow-up time (days)"),
  theme  = "lancet",   # 期刊预设,管的是线宽、字号和 p 值格式
  save   = FALSE       # 只返回对象;给路径才落盘
) |>
  # lancet 主题自带粉色底纹;显式覆盖为本页从 heat_light 生成的浅蓝
  tab_style(cell_fill(SHADE), cells_column_labels()) |>
  tab_style(cell_fill(SHADE), cells_body(rows = c(1, 7)))
tableone — wrapped
tableone-wrapped

3 · tbl_merge() / tbl_stack()

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

#| fig: merge
#| out: gt
by_sex <- table_data |>
  select(sex, age_years, ph_ecog) |>
  tbl_summary(
    by = sex, missing = "no",
    label = list(age_years ~ "Age (years)",
                 ph_ecog ~ "ECOG performance score")
  )

by_event <- table_data |>
  select(event, age_years, ph_ecog) |>
  tbl_summary(
    by = event, missing = "no",
    label = list(age_years ~ "Age (years)",
                 ph_ecog ~ "ECOG performance score")
  )

merged <- tbl_merge(
  list(by_sex, by_event),
  tab_spanner = c("**By sex**", "**By outcome**")
)

# 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
ecog_0 <- table_data |>
  filter(ph_ecog == "0") |>
  select(sex, age_years, time_days) |>
  tbl_summary(
    by = sex, missing = "no",
    label = list(age_years ~ "Age (years)",
                 time_days ~ "Follow-up time (days)")
  )

ecog_1 <- table_data |>
  filter(ph_ecog == "1") |>
  select(sex, age_years, time_days) |>
  tbl_summary(
    by = sex, missing = "no",
    label = list(age_years ~ "Age (years)",
                 time_days ~ "Follow-up time (days)")
  )

stacked <- tbl_stack(
  list(ecog_0, ecog_1),
  group_header = c("ECOG 0", "ECOG 1"),
  quiet        = TRUE
) |>
  # 只留 {level},把 N 从表头去掉:那个 N 只对上半块成立
  modify_header(all_stat_cols() ~ "**{level}**")

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 的样式没生成,是浏览器打印时把底色扔了。

Word 单独说。 as_flex_table() 转出来的 docx 是没有样式的——所有 tab_style() 都加在 gt 对象上,而 flextable 是从 gtsummary 对象转的,它根本没见过那些样式。gtsave(g, "t1.docx") 倒是把底纹写进了 XML,但排版仍然是 gt 自己那一套。实际交付里更可靠的路子是先出 PDF,再用 Foxit 之类的工具转 Word:拿到的是所见即所得的版式,编辑部那边也能直接改。

#| 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
)

Constraints

  • 默认不显示缺失。 missing = "no" 是 tbl_summary() 的默认值:ph_ecog 的缺失值会一个字都不提,百分比却已经按去掉 NA 的分母算好了。"我没有缺失"和"我不想显示缺失"在表上长得一模一样。
  • 百分比的分母有三种。 percent = "column"(默认)是组内占比,"row" 是这一行里各组的构成,"cell" 是占总体。选错了整列数字都是错的,而三者都是形如 35 (36%) 的合法数字,表面看不出来。
  • 统计量由分布决定,不由习惯决定。 偏态变量用中位数 (IQR),近似正态才用均值 (SD)。同一张表里混用可以,但哪个是哪个只有脚注说了算。
  • RCT 的 Table 1 里 p 值没有信息量。 随机化本身保证了基线差异来自偶然,检验一个已知为真的原假设得不到任何东西,CONSORT 也不建议;要衡量不平衡的程度用 SMD。观察性研究不同,那里 p 值是站得住的。

三个 recipe 怎么比

  1. 常规 Table 1 → recipe 2。一行出结果,样式和导出都定好了。
  2. 有期刊特定要求 → recipe 1。变量级脚注、caption、非标准的分块规则,封装的参数表里没有。
  3. 要对比两种分组或两个亚组 → recipe 3。前提是三线表的顶线一开始就写对(走 table.border.top),否则拼表会把它顶歪;stack 之后还要记得把表头的 N 去掉。
运行环境6 个包 · 2026-09-02T13:03:48.989+0800

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

  • biopalette 0.2.2
  • dplyr 1.2.1
  • gt 1.0.0
  • gtsummary 2.4.0
  • pagedown 0.23
  • ukbflow 0.4.0