☰
R语言tableone基线特征表与三线表Word/PDF导出全流程
2026/10/27 15:18:59 网站建设 项目流程

做人群研究、临床观察或者任何需要描述样本结构的分析,最后都要落到一张基线特征表上,圈里人管它叫 Table 1。我第一次被要求把 R 跑出来的结果做成能直接塞进投稿系统的 Word 表格时,折腾了整整一个下午:从控制台复制粘贴过去,列宽全乱,小数位对不齐,改完 p 值又得从头粘一遍。后来摸清了 R 语言里 tableone 包的脾气,再把三线表那几条边框规则固定下来,这套流程才算真正稳定——现在从原始数据到一张可以直接送审的表,熟练的话十分钟以内能收工。下面这些内容,是我在反复返工中攒下来的完整流程:tableone 怎么用、参数怎么选、三线表怎么落地到 Word 和 PDF,以及那些文档里没写、只有真跑过一遍才会撞上的坑。适合已经会用 R 做基本数据处理、但还没把制表流程跑顺的人,也适合刚接手数据整理、被导师要求"表一重做"的同学照着抄。

1. 手工拼基线表的日子该结束了:tableone 到底省掉了哪几道工序

1.1 一张"表1"背后的重复劳动清单

没写过自动化脚本的人,通常是这样干的:对每个连续变量算两组的均值和标准差,做一次 t 检验;对每个分类变量数四个格子里的频数,算百分比,做一次卡方;然后把二三十个数字一个个填进 Excel,再手动写"均值 ± 标准差"的格式,小数点保留两位,p 值小于 0.001 的统一写成"<0.001"。这一套下来,二三十个变量就是上百次机械操作,中间任何一个数字录错,整张表都要重来。

真正的麻烦不在工作量,在于一致性和可复现性。数据清洗阶段改动了一个异常值,或者补录了三个缺失样本,手工表就得整体重做一遍,而且很容易漏改某一格。审稿人一句"请补充标准化均数差",你可能要重新打开 SPSS 或者 Excel 重新算一遍。

tableone 这个包解决的正是这件事:你把变量名单、分组变量、哪些是分类变量这三件事告诉它,它自动完成分组描述、假设检验、效应量计算,一次性输出一份可以直接排版的矩阵。它不负责画图,也不做建模,定位非常清晰——只把描述性统计这一步流水线化。

1.2 它返回的不是表格,是一坨还能继续加工的矩阵

这一点必须先讲清楚,因为它决定了后面所有的排版路线。CreateTableOne()返回的是一个 S3 对象,控制台里看到的漂亮排版只是print方法的副作用。真正有价值的是:调用print(tab, printToggle = FALSE)之后,你会拿到一个字符型矩阵,行列结构清晰,每一格都是已经格式化好的字符串(比如"23.4 (4.1)")。

提示:拿到矩阵后先用class()和str()看一眼,再决定用哪个包去渲染。很多人卡在"为什么 kable 出来是全白的",就是因为把 tableone 对象直接丢给了渲染函数,而没有先print(..., printToggle = FALSE)。

这个设计其实很聪明。因为 tableone 只负责"算得对、格式对",至于这张表最后是 HTML、Word 还是 PDF,交给 kableExtra、flextable、gt 这些专门的排版包去做。职责分离之后,你可以只写一次统计逻辑,输出端按需切换。

对比一下几种常见做法的差异:

方案上手成本分组检验SMD导出三线表适合场景
Excel 手工极低需另做手算手工设边框变量少于 8 个、一次性
tableone + flextable中等内置内置原生支持常规投稿、需要反复更新
gtsummary中等内置内置支持喜欢 tidyverse 语法
自己写循环 + kable较高全手写全手写原生支持有特殊统计需求

我个人的取舍是:常规 Table 1 一律用 tableone,因为它的默认检验策略(连续变量 t 检验 / 秩和检验,分类变量卡方 / Fisher)和多数中文期刊的习惯基本吻合,不用额外解释。只有在需要嵌套表格、合并多行表头这类复杂结构时,才换成 gtsummary。

2. 数据进炉前的预处理:三类变量分流与缺失值定档

2.1 连续、分类、二分类的判定与 factor 化的时机

tableone 判断一个变量怎么描述,靠的是 R 的对象类型,不是你的意图。numeric类型一律按连续变量处理,算均值标准差(或者中位数四分位数);factor和character类型按分类变量处理,数频数算百分比。这里最容易出错的地方是:用 0/1 编码的二分类变量,如果没转成 factor,会被当成连续变量算出一个"0.47 ± 0.50"的均值,看上去还挺像那么回事,实际上是错的。

所以我的习惯是,建模之前先做一次显式的类型声明,把所有分类变量列出来,一次性转干净:

library(tableone) df <- read.csv("cohort.csv", stringsAsFactors = FALSE, fileEncoding = "UTF-8") # 分类变量统一转 factor,并显式指定水平顺序 df$sex <- factor(df$sex, levels = c(0, 1), labels = c("女", "男")) df$arm <- factor(df$arm, levels = c(0, 1), labels = c("对照", "试验")) df$stage <- factor(df$stage, levels = c("I", "II", "III"), ordered = FALSE)

顺序为什么重要?因为showAllLevels = FALSE时,tableone 只显示"非参考水平 vs 参考水平"这一行,参考水平默认是第一个 level。如果水平顺序反了,表里就会变成"男 vs 女"的奇怪表述。另外多分类变量的stage,顺序混乱会导致表格行序难以阅读,所以在 factor 阶段就把顺序定死,比事后调整省事得多。

还有一个细节:有序分类变量(比如分期、等级)如果设成ordered = TRUE,tableone 的某些检验选择会受影响。我一般统一设成无序 factor,检验方式手动指定,可控性更好。

2.2 缺失值到底算不算一类:includeNA 的取舍

includeNA这个参数默认是FALSE,意思是含缺失的观测在计算该变量时被剔除。这里要提醒一句:tableone 做的是逐变量剔除(available-case),不是整行删除。也就是说,年龄缺失的 3 个人在年龄那一行不算,但在性别那一行照常计入。这跟你后面跑完整案例回归时用的样本量会不一致,写方法学部分时必须交代清楚。

如果缺失比例不高(比如低于 5%),常规做法是保持默认,然后在表注里写一句"各变量缺失情况见表注"。如果缺失比例较高,或者缺失本身有临床意义(比如"未检测"代表病情轻),那就要考虑把 NA 单独算作一个水平:

tab <- CreateTableOne(vars = myVars, strata = "arm", data = df, factorVars = catVars, includeNA = TRUE)

注意includeNA = TRUE只对分类变量有效,连续变量的缺失仍然是剔除。还有一个坑:只有当factorVars里明确列出了这个变量,includeNA才会把它作为独立水平展示;如果忘了写进factorVars,NA 会被静默丢掉,你在表里根本看不出来。

2.3 分组变量本身的三条硬性要求

strata参数看着简单,但翻车率不低。三条必须满足:

第一,必须是 factor,不能是 numeric。如果分组变量是 0/1 数值,tableone 会把它当连续变量处理,直接报错或者给出莫名其妙的结果。第二,水平数不要超过 4 个。三组以上的表格横向会非常宽,Word 里几乎排不下,通常建议改成两组主对比,其他组放补充材料。第三,分组变量的缺失必须提前处理。strata里有 NA 的观测会被整行剔除,而且不会给你任何提示——你只会发现各组人数加起来对不上总数。

我一般在建表前先跑一次核对:

table(df$arm, useNA = "always")

确认没有 NA、水平数符合预期,再往下走。这一步只花两秒钟,能省掉后面半小时的排查。

3. CreateTableOne 的参数地图:哪些开关真能改变数字

3.1 vars、strata、factorVars 这三个必填项怎么组织

vars是你要展示的变量名向量,顺序就是表格里的行顺序。我的习惯是按逻辑分组排:先人口学(年龄、性别),再临床指标(BMI、血压、实验室检查),最后结局相关变量。这样表读起来有节奏,审稿人找信息也快。

变量名多的时候,别手打,用dput(names(df))把列名导出来,再挑需要的复制进去,能避免拼写错误:

dput(names(df)) myVars <- c("age", "sex", "bmi", "sbp", "dbp", "hba1c", "dm", "stage") catVars <- c("sex", "dm", "stage") tab <- CreateTableOne(vars = myVars, strata = "arm", data = df, factorVars = catVars)

factorVars这个参数的作用不只是控制描述方式,它还影响检验方法的选择:只有被声明为分类变量的,才会走卡方或 Fisher 检验。漏写一个,就会得到前面说的"0.47 ± 0.50"那种结果。

3.2 nonnormal 和 exact:两个最容易被默认值坑到的开关

这是我认为 tableone 里最需要主动干预的两个参数,默认值都很"宽容",可能导致结果不符合实际情况。

nonnormal控制哪些连续变量用中位数(四分位数间距)描述,并改用非参数检验。默认全部按正态处理,也就是全部算均值标准差、做 t 检验。但现实中偏态分布的指标一大堆(住院天数、某些炎症因子、费用数据),硬套 t 检验会被审稿人指出来。判断标准我一般用两条:样本量小于 30 的组,或者直方图/偏度明显偏斜(偏度绝对值大于 1)。确认偏态后,把变量名塞进nonnormal:

tab <- CreateTableOne(vars = myVars, strata = "arm", data = df, factorVars = catVars, nonnormal = c("los", "crp"))

exact控制哪些分类变量用 Fisher 精确检验。默认走卡方,但当理论频数小于 5 的格子超过 20% 时,卡方检验不可靠。tableone 不会自动帮你判断,得自己看。我的做法是先跑一遍默认的,输出里带期望频数信息不太直观,所以通常是直接对样本量小的分类变量(比如某些罕见并发症)加exact:

tab <- CreateTableOne(vars = myVars, strata = "arm", data = df, factorVars = catVars, nonnormal = c("los"), exact = c("stage", "complication"))

注意:exact和nonnormal这两个参数在CreateTableOne()和print()两个阶段都可以传,但只在 print 阶段传才影响最终呈现的 p 值。我踩过的坑是:在 CreateTableOne 里写了 nonnormal,结果 print 的时候没写,输出还是均值标准差。稳妥做法是两处都写,或者干脆只在 print 里写。

3.3 addOverall、smd、test 的取舍

addOverall = TRUE会在第一列加一个"Overall"全样本列。什么时候要?如果文章里只做两组对比,通常不需要,表宽也吃紧。如果做了多组对比,或者想展示总体分布,加上更直观。这个参数在不同小版本里可能挂在CreateTableOne()或者print()上,敲不出来就?print.TableOne看一眼文档,不丢人。

smd = TRUE输出标准化均数差。现在主流期刊(尤其是观察性研究)越来越看重 SMD 而不是 p 值,因为 p 值受样本量影响太大,大样本下一点微小差异都能显著。经验阈值是SMD 大于 0.1 提示组间不平衡,这个数字比 p 值稳定得多。加上这一列,表格会宽一点,但审稿时能省掉一轮追问。

test = FALSE会关掉所有 p 值。什么场景用?一是纯描述性研究,不做组间比较;二是某些期刊明确反对在基线表里放 p 值(这个观点在方法学圈里有不少支持者,理由是随机分组下的组间差异本就是随机的)。国内期刊目前多数还是要求 p 值,所以默认开着比较安全。

3.4 print 阶段的显示控制:noSpaces、showAllLevels、dropEqualLevel

print()这一层才是真正决定表格长相的地方,几个参数值得单独说。

noSpaces = TRUE会去掉列名里的空格。为什么重要?因为不设这个参数,列名会变成"arm=0"之类带空格的形式,导出到 Word 后表头对齐会很难看,而且有些渲染函数对含空格的列名处理会出问题。养成习惯,永远加上。

showAllLevels = TRUE让多分类变量的每个水平都单独占一行。默认FALSE时,tableone 会把多分类变量压成一行显示("stage: II vs I, III vs I"这种紧凑格式),可读性差。三线表讲究一行一个信息,所以基本都要开。

dropEqualLevel = TRUE是个省版面利器:当某个分类变量的所有水平在两组间人数完全相同时,把重复的行合并掉。用得不多,但遇到"所有人都阳性的某个筛查指标"这种变量时很管用。

catDigits、contDigits、pDigits控制小数位。默认是 1、2、3。我的建议是:百分比统一 1 位小数,连续变量 2 位(血压之类可以 1 位),p 值 3 位。全部统一,表看起来才整齐。

minMax = TRUE会把连续变量的描述从中位数(IQR)改成中位数(最小值-最大值),适合样本量很小、四分位数意义不大的情况。

4. 落地成三线表的三条路线:HTML、Word、PDF 各走各的

4.1 kableExtra 配 booktabs:最快的一条路

三线表的核心规则只有三条:顶线粗、表头下方一条细线、底线粗,中间和两侧一根竖线都没有。这套规则来自学术出版的传统排版规范,在 LaTeX 里对应booktabs宏包的\toprule、\midrule、\bottomrule。kableExtra 直接把这套东西搬过来了。

完整流程是这样:

library(knitr) library(kableExtra) tabMat <- print(tab, nonnormal = c("los"), exact = c("stage"), showAllLevels = TRUE, quote = FALSE, noSpaces = TRUE, printToggle = FALSE, smd = TRUE, catDigits = 1, contDigits = 2, pDigits = 3) colnames(tabMat)[1] <- "变量" kbl(tabMat, booktabs = TRUE, linesep = "", align = "lcccc", caption = "表1 两组基线特征比较") %>% kable_classic(full_width = FALSE) %>% row_spec(0, bold = TRUE)

几个关键点:linesep = ""去掉行与行之间的横线,否则每行都会有一条细线,那就不是三线表了。align = "lcccc"第一列左对齐、其余居中,符合中文期刊习惯。如果输出 PDF,直接走booktabs = TRUE就行;如果输出 HTML,kable_classic会给你一个接近三线表的样式。

tabMat的列顺序大致是:第一列 level(变量名与水平),然后是 Overall(如果开了 addOverall),接着是各分组列,最后是 p 和 test(开了 smd 时再多一列 SMD)。不同版本可能略有差异,拿到矩阵后colnames(tabMat)看一眼,再决定要不要重命名表头。

4.2 flextable 配 officer:往 Word 里塞一张真正的三线表

投稿九成以上要 Word 稿,所以这条路最实用。flextable 的优势是它生成的是真正的 Word 表格对象,不是图片,编辑时可以直接改。

library(flextable) library(officer) tabDf <- as.data.frame(tabMat, stringsAsFactors = FALSE) tabDf <- cbind(变量 = rownames(tabMat), tabDf) tabDf$变量 <- NULL # 若第一列已是变量名,避免重复 ft <- flextable(tabDf) ft <- theme_booktabs(ft) # 三线表主题 ft <- font(ft, fontname = "Times New Roman", part = "all") ft <- fontsize(ft, size = 9, part = "all") ft <- bold(ft, part = "header") ft <- align(ft, align = "left", j = 1, part = "all") ft <- align(ft, align = "center", j = 2:ncol(tabDf), part = "all") ft <- set_table_properties(ft, layout = "autofit", width = 1) ft <- autofit(ft) doc <- read_docx() doc <- body_add_flextable(doc, ft) print(doc, target = "table1.docx")

theme_booktabs()已经把顶线、表头线、底线设好了,线宽分别是 1.5 磅、0.75 磅、1.5 磅,正是中文期刊对三线表的规定。如果期刊对线宽有特殊要求,可以手动调:

ft <- border_remove(ft) ft <- hline_top(ft, border = fp_border(width = 1.5), part = "header") ft <- hline_bottom(ft, border = fp_border(width = 0.75), part = "header") ft <- hline_bottom(ft, border = fp_border(width = 1.5), part = "body")

注意border_remove()要先执行,把所有默认边框清空,再一条条加上去,否则会残留竖线。这个顺序反了的话,你会看到一条莫名其妙的左边框。

中文变量名的话,字体建议设成系统自带的中文字体(比如"宋体"或"微软雅黑"),否则生成出来的 docx 打开可能是方块。更稳妥的做法是在font()里分别指定中西文字体,flextable 有fontname参数可以传两个值。

4.3 gtsummary 作为备选:什么时候该换工具

gtsummary 用 tidyverse 语法,写法是tbl_summary(df, by = arm)然后add_p()、add_difference()。它的优势是管道风格统一,和 dplyr 一套体系;输出端有as_flex_table()、as_kable()、as_gt(),一键切换。

但它也有代价:默认样式不是严格的三线表,theme_gtsummary_journal("jama")只是接近,边框细节还得自己调;多分类变量的行命名规则和 tableone 不同,中途换工具意味着整张表的结构要重新适应。

我的建议是:如果你已经在用 tableone 并且流程跑通了,别换。只有在需要做跨表拼接(比如把基线表和亚组分析表并排)、或者需要输出多层嵌套表头时,才值得迁移。

5. 实测坑位清单:从乱码到 p 值,一条条对照

5.1 分类变量被当成了连续变量

症状:表里出现"性别 0.48 ± 0.50"这种明显不对的描述。

根因有两层。第一层是数据类型没转,df$sex还是 numeric。第二层更隐蔽:类型转了,但factorVars里漏写了这个变量名。tableone 在读数据时会重新判定类型,如果factorVars没声明,某些情况下会按数值处理。

排查链路是先class(df$sex)确认类型,再检查factorVars里有没有它,最后用dim(print(tab, printToggle = FALSE))看行数——分类变量如果被正确处理,行数应该等于水平数。

5.2 中文乱码与列宽溢出

乱码基本出在读取环节。read.csv()在 Windows 上读 UTF-8 文件时,中文列名会变成问号或乱码。解决办法是显式指定编码:

df <- read.csv("cohort.csv", stringsAsFactors = FALSE, fileEncoding = "UTF-8") # 若仍乱码,试试 "GBK";或用 readr::read_csv 自动探测

另一个常见问题是表格宽度超出页面。三线表讲究一页装下,变量太多时要么拆成"表1a / 表1b",要么删减变量。实用技巧是在flextable里设set_table_properties(layout = "autofit")并给width = 1,让它按页面宽度自动压缩;实在压不下去,就把字号从 9 降到 8,或者把长变量名手动换行(在字符串里插\n)。

我个人的经验是:变量超过 15 个就该考虑拆表,硬塞进一张表,读者根本看不清。

5.3 p 值不对的三个来源

第一个来源是检验方法选错。默认卡方在期望频数小的场景下不可靠,需要手动加exact。第二个来源是连续变量的正态性误判,偏态变量走了 t 检验,p 值会偏。第三个最隐蔽:nonnormal和exact只写在CreateTableOne()里,没写在print()里。因为 p 值是在 print 阶段最终计算并格式化的,只写在一处不生效。

排查方法很直接:把同一张表用两套参数分别 print 出来,对比 p 值列,有差异就说明参数没传对位置。

5.4 行名丢失与合并单元格

从print()拿到的矩阵,行名是有意义的(变量名 + 水平),但as.data.frame()之后行名会变成普通的行索引,导出时第一列就空了。所以必须手动补回来:

tabDf <- data.frame(变量 = rownames(tabMat), tabMat, stringsAsFactors = FALSE, check.names = FALSE) rownames(tabDf) <- NULL

还有一个排版细节:在真正的三线表里,同一个分类变量的多个水平通常只显示一次变量名,下面几行留空。tableone 的矩阵行名是"stage"和"stage: II"、"stage: III"这种形式,导出后可以用 flextable 的merge_v()把第一列相同内容的行合并,视觉上更接近期刊要求。

6. 表格定稿前的最后一遍检查:三线表的规格与自审清单

6.1 线宽、字号、对齐的硬指标

中文期刊对三线表的要求基本一致:顶线和底线 1.5 磅(约 0.5 mm),栏目线 0.75 磅,全表无竖线,表内文字字号比正文小一号(正文小四的话,表内用五号,约 9 pt),表头加粗。数字列右对齐或小数点对齐,文字列左对齐,其余居中。

如果往 Word 里手动调,路径是:选中表格 → 边框和底纹 → 先"无框线"清空 → 再分别设置上框线(1.5 磅)、内部横线中的表头下框线(0.75 磅)、下框线(1.5 磅)。这一步手动做十次也就熟了,但每次改数据都要重做,所以我更推荐上面 4.2 那套脚本,改完数据重跑一遍就行。

另一个容易被忽略的点:表注。表注要说明缩略语、缺失情况、检验方法。比如"均值 ± 标准差;中位数(四分位数间距);* 采用 Fisher 精确检验"。这一段别写在表格单元格里,写在表格下方作为正文段落,格式上更规范。

6.2 一份可以照着念的自审清单

每次出表前我会过一遍下面这几条,基本上能拦掉九成返工:

检查项判断标准常见问题
变量类型分类变量全部为 factor 且列入 factorVars二分类变量被算成均值
描述格式正态用均值±SD,偏态用中位数(IQR)偏态变量硬套 t 检验
缺失处理明确 available-case 还是 includeNA各组人数加起来对不上
检验方法期望频数小于 5 时用 exact卡方 p 值不可靠
小数位百分比 1 位、连续 2 位、p 值 3 位同一列位数不统一
p 值下限小于 0.001 统一写"<0.001"出现 0.000
SMD是否超过 0.1组间不平衡未提示
边框无竖线,三线齐全残留内部横线
字体中文不出现方块未指定中文字体
宽度单页可容纳列被截断

最后再分享一个我用了很久的小技巧:把整张表的生成逻辑(读数据、转因子、建表、print、渲染、导出)封装成一个函数,参数只留文件名和变量清单。这样换一个数据集,改两行就能重跑,不用从头复制代码。等你要同时处理三四个队列的时候,会非常感谢当初多花的这二十分钟。

变量特别多、又想在一张表里塞下全部信息的时候,还有一个折中方案:正文放精简版(只保留核心 10 个变量),补充材料放完整版,两版用同一份脚本生成,改一处参数即可。审稿人问起来,直接把补充材料编号报过去,比临时重算省事得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询