Introduction
论文里的第一张表几乎总是它:把人按组分开,逐个变量报一遍基线。它回答的问题只有一个——这两组人在开始的时候可不可比。
它和这一栏别的条目不一样:表格不是图。但它受的是同一套约束——投稿要的格式、底纹配色、导出,以及"代码改了,产物得跟着变"。所以它在这儿。
读的时候按这个顺序:
- 先看表头的 N。 后面所有百分比的分母都在那儿。
- 再看脚注里的统计量约定。
46 (37, 60)是中位数还是均值,只有脚注说了算。 - 看有没有 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)



