什么时候用它
论文里的第一张表,几乎总是它:把人按组分开,逐个变量报一遍基线。它回答的问题只有一个——这两组人在开始的时候可不可比。
它和这一栏别的条目不一样:表格不是图。但它受的约束是同一套——投稿要的格式、底纹配色、导出,以及"代码改了图得跟着变"。所以它在这儿。
怎么读
- 先看表头的 N。 后面所有百分比的分母都在那儿。
- 看脚注里的统计量约定。
46 (37, 60) 是中位数还是均值,只有脚注说了算。
- 看有没有 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 行(标签行加各个水平),按行号会把同一个变量从中间劈开。
配方
一、画出来
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
底色版。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
二、一行版
上面那四十行如果每张表都要重写一遍,迟早会写歪。ukbflow 里 plot_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
它比手写版多做了几件事: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
不同的人按同一套变量报,用 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
四、存成文件
按负担从小到大排:
| 目标 |
写法 |
结果 |
| 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 里重排一遍。
三种做法怎么选
- 常规 Table 1 →
plot_tableone()。 一行,样式和导出都定好了。
- 有期刊特定要求 → 手写
tbl_summary() + gt。 变量级脚注、caption、非标准的分块规则,这些封装的参数表里没有。
- 要对比两种分组或两个亚组 →
tbl_merge() / tbl_stack()。 前提是三线表的顶线一开始就写对(用 table.border.top),否则拼表会把它顶歪;stack 之后还要记得把表头的 N 去掉。