ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

tableone基线表:连续型与分类变量的输出、正态性判据与检验选择

tableone基线表:连续型与分类变量的输出、正态性判据与检验选择

上一次帮人看一份基线表,作者把年龄写成了 58.3 ± 11.7,审稿意见只有一句话:年龄分布明显偏态,请改用中位数和四分位数间距。作者很委屈,说软件自动算的就是均数。我打开脚本一看,CreateTableOne里什么参数都没写,全靠默认值跑出来的。问题就出在这里:tableone 默认会自己对连续型变量做一次正态性判断,判断结果直接决定表里印均数还是印中位数,而这个判断过程是静默的,不会在表里留任何痕迹。分类变量那边也是同样的道理,showAllLevels、exact、categorical这几个参数谁都没设,出来的表看着挺整齐,但水平展开方式和检验方法可能完全不是你想要的那一套。

这篇就专门聊 tableone 里连续型变量和分类变量这两条主线。前面几篇把安装、数据准备、基本调用过了一遍,这一篇往深里走:正态性判据到底在哪一步生效、nonnormal怎么用才不出错、分类变量的水平怎么展开、Fisher 精确检验什么时候必须上、有序分类变量为什么常常要绕道处理。适合已经能把表跑出来、但对结果不太放心的人看;也适合刚接手别人脚本、想搞清楚每一列是怎么来的朋友。文中所有数据都是模拟构造的,代码可以直接抄去改。

1. 那次被退回来的基线表:连续型与分类变量的输出其实只有两条路

tableone 对变量的处理逻辑非常干脆:一个变量进来,先判断它是连续型还是分类型,然后走两条完全不同的分支。连续型走"集中趋势 + 离散程度"的路线,输出均数(标准差)或者中位数(四分位数间距);分类型走"计数 + 占比"的路线,输出 n (%)。两条分支各有各的组间比较检验,各有各的参数开关,混在一起讲容易乱,所以先把这两条路的骨架摆出来。

判断变量类型的依据,是数据框里这一列的类型。数值型(numeric / integer)默认进连续型分支,因子型(factor)和字符型(character)默认进分类型分支。这里第一个坑就出现了:很多原始数据里,性别用 1/2 编码、分级用 1/2/3/4 编码,读进来全是数值型,tableone 会老老实实把它们当连续变量算均数。你会得到一行"性别 1.52 ± 0.50"这样荒谬的结果。这个问题后面第 4 节专门讲,先记住一句话:类型决定分支,编码方式不决定分支。

连续型分支里,tableone 还要再做一次判断:这个变量服从正态分布吗?服从就印均数(标准差),不服从就印中位数 [P25, P75]。这个判断默认由 Shapiro-Wilk 检验完成,按分组逐组做,任何一组拒绝正态,整个变量就按非正态处理。所以同一个变量,stratify 的变量换一下,输出的形式可能就变了,这一点很多人没意识到。

分类型分支里,tableone 要做的是把因子的每一个水平展开成一行(或者只留第一行),然后根据单元格频数决定用卡方还是 Fisher。展开方式和检验方式都由参数控制,默认值未必适合你的表。举个最常见的场景:一个五分类的变量,showAllLevels = TRUE展开成五行,如果表里有七八个这样的变量,一张表能拉到三页纸,编辑部大概率会要求压缩。

分支默认输出形式组间比较默认方法关键开关
连续型(正态)mean (SD)oneway.test(两组即 Welch t)nonnormal
连续型(非正态)median [P25, P75]kruskal.testnonnormal、minMax
分类型(无序)n (%)chisq.test(2×2 带连续性校正)exact、showAllLevels
分类型(有序)n (%) 或 median [IQR]chisq / kruskalnonnormal、categorical

这张表建议贴在脚本注释里。每次出表前扫一眼,问自己三个问题:这一列的变量类型对吗?正态判断的结果是我认可的吗?组间比较用的检验是我想在方法学段落里写的那一个吗?三个问题都过了,表基本就不会出大问题。

2. 连续型变量的正态性判据:tableone 到底在哪一步替你做了决定

2.1 分组内 Shapiro-Wilk 检验的执行顺序

nonnormal参数不指定的时候,tableone 不会问你,直接自己判。判的方式是在每个分组内部对连续变量做一次 Shapiro-Wilk 正态性检验,显著性水平取 0.05。注意是分组内,不是整体。假设你按治疗组分成试验组和对照组,年龄在试验组 p = 0.31,在对照组 p = 0.04,那么整个年龄变量就被判为非正态,两组都印中位数。

这个逻辑本身是保守的,出发点是"不能一个组印均数、另一个组印中位数",那样表就没法看了。但它带来两个副作用,值得提前想清楚。

第一个副作用是分组越多越容易翻车。你把 strata 从一个三分类变量换成一个六分类变量,每个格子里的样本量掉一半,Shapiro-Wilk 的检验效能跟着下降,p 值反而可能变大也可能变小,结果就是同一个变量在两张表里的呈现形式不一致。这种情况在亚组分析里特别常见,主表用均数、亚组表用中位数,审稿人一眼就看出来了。

第二个副作用是大样本下几乎必然拒绝正态。Shapiro-Wilk 检验对偏度非常敏感,样本量上到几千,哪怕分布只是轻微右偏,p 值也会远小于 0.05。真实的临床数据里,住院天数、费用、某些指标几乎没有真正对称的,所以大样本研究里"自动判出来全是中位数"是常态。这时候反而要考虑是不是该手动指定一部分变量按正态处理,因为均数在群体层面的解释性更好,而且很多模型(比如线性回归)对自变量的分布并没有硬性要求。

提示:Shapiro-Wilk 检验在 R 里要求样本量在 3 到 5000 之间。某一组的样本量超过 5000 时,这个检验会直接报错,tableone 那一步就过不去。处理办法只有两个:手动指定nonnormal,或者先对数据做随机抽样判断后再指定。这一点在跑大型队列数据时几乎一定会遇到。

还有一个细节值得说:tableone 内部用来判正态的就是shapiro.test,没有开放参数让你换成 Anderson-Darling 或者偏度峰度法。想换判据,只能自己先算好,再把结论通过nonnormal传进去。我个人的习惯是:出表之前先自己画一遍直方图或者密度图,对每个连续变量的形态心里有数,然后用nonnormal显式指定,尽量不让它自动判。这样表的呈现形式是确定的,换个分组变量也不会变,复现性好得多。

2.2 nonnormal 参数的四种写法与覆盖时机

nonnormal可以在CreateTableOne里给,也可以在print里给,两处都行,效果一样。写法有几种,各有适用场合。

第一种是逻辑值:nonnormal = TRUE表示所有连续变量都按非正态处理,全部印中位数。适合数据整体偏态明显、或者你压根不想纠结判据的场景。缺点是粒度太粗,一个变量都跑不掉。

第二种是字符向量:nonnormal = c("los", "cost", "crp"),精确点名。这是最常用的写法,可读性好,写进脚本里别人一看就懂。建议在脚本顶部把这类变量名定义成变量,别散落在调用里。

第三种是公式写法:nonnormal = ~ los + cost + crp。适合变量多、想少打几个引号的情况。功能上和字符向量等价。

第四种是逻辑向量,长度和变量数一致,按位置对应。不推荐,因为变量顺序一变就错位了,而且没人记得住第几个是哪个变量。

# 三种等价写法,选一种固定下来 print(tab, nonnormal = c("los", "cost", "crp")) print(tab, nonnormal = ~ los + cost + crp) print(tab, nonnormal = TRUE) # 全部按非正态

覆盖时机上有个容易混淆的点:CreateTableOne阶段给的nonnormal和print阶段给的会叠加吗?实测下来是以print阶段的为准,也就是说你在建表时指定了一批,打印时又指定另一批,最终按打印时的来。这个行为不算坑,但如果你把建表封装成一个函数、打印又单独写,很容易忘了其中一处,导致表的形式和预期不一致。我的做法是只在print里指定,建表那一步保持干净。

另外提一句,非正态变量的组间比较默认走kruskal.test。两组的时候它就是 Wilcoxon 秩和检验,p 值和wilcox.test基本一致(有连续性校正的细微差别)。多组就是 Kruskal-Wallis。方法学段落里描述的时候要写准,别写成"采用 Wilcoxon 检验"却在三组数据上跑,那是描述与实现不符。

2.3 均数标准差、中位数四分位数间距与 min-max 的呈现取舍

输出形式的选择不只是统计问题,也是表达问题。均数(标准差)适合描述对称分布,读者能直接感受到"平均水平 ± 波动范围";中位数 [P25, P75] 适合偏态分布,对极端值不敏感。这两者在临床表里都有各自的地盘,比如年龄、身高、体重指数通常用均数,住院天数、费用、某些炎症指标通常用中位数。

print里有个minMax参数,设成TRUE之后,连续变量的输出会变成median [P25, P75, min, max]这样四段式。有人喜欢这么写,觉得信息量大;我不太推荐在正文表里用,因为一列里塞四个数字,表格宽度撑不住,而且最小值最大值本质上是极端观测,对描述总体 центральная趋势帮助有限。真要放,放在附录表里更合适。

contDigits控制连续变量的小数位数,默认 2。这个默认值对大多数指标是合适的,但有两类情况要调:一是本身就是整数的变量(比如住院天数、发作次数),印成7.00 (3.00, 12.00)很别扭,设成 0 更清爽;二是精密度很高的实验室指标,可能需要 3 位。同一个表里不同变量想要不同位数,contDigits做不到,它只能全局设一个值。变通办法是用formatOptions配合后期手工调整,或者干脆导出成数据框再改。

还有一个很实用的参数是varLabels,用来给变量加可读标签。原始数据里的列名往往是sbp、hba1c这种缩写,直接印在表里读者看不懂。用命名向量把标签挂上,输出的表就自带中文或英文全称,省得后期在 Word 里一个个改。

varLabels <- c( age = "Age, years", bmi = "Body mass index, kg/m2", sbp = "Systolic blood pressure, mmHg", los = "Length of stay, days" ) print(tab, nonnormal = c("los"), contDigits = 2, varLabels = varLabels, minMax = FALSE)

los这种以天为单位的变量,如果设contDigits = 0,中位数会印成7 [4, 12],读起来干净很多。但注意这会同时影响所有连续变量,所以要么全表统一 0,要么这一列单独处理。这类细节看着小,但一张表几十行,位数不统一会显得很不专业。

2.4 检验链条:oneway.test 与 kruskal.test 是怎么被选中的

组间比较的检验不是随便挑的,tableone 有一套默认映射,搞清楚这套映射才能在看 p 值的时候心里有底。

连续变量判为正态,走oneway.test,参数是var.equal = FALSE。两组的时候,这就是 Welch t 检验,不假设方差齐性。这一点和很多人的预期不一样——很多人以为默认是 Student t 检验,实际上是 Welch。两者在方差接近时结果几乎一致,方差悬殊时 Welch 更保守,自由度是小数。方法学里如果要写,得写"Welch t 检验"或者"不假设方差齐性的 t 检验",写成"t 检验"严格说不准确。有些期刊会追问这个细节,提前写清楚省事。

连续变量判为非正态,走kruskal.test。两组是 Wilcoxon 秩和,多组是 Kruskal-Wallis。这个没什么争议。

分类变量走chisq.test,参数correct = TRUE。这是 Pearson 卡方加 Yates 连续性校正。R 里correct参数只对 2×2 表生效,三分类及以上的表会自动忽略,所以你会看到有些变量的 p 值和手算的完全一致,有些差一点,原因就在这里。如果审稿人拿统计软件复核,2×2 表那一行的 p 值对不上,百分之八九是连续性校正的锅。

场景默认检验关键参数常见困惑
正态连续、两组Welch t(oneway.test)var.equal = FALSE与 Student t 结果略有差异
正态连续、多组oneway.testvar.equal = FALSE不是方差分析
非正态连续、任意组数Kruskal-Wallis无两组时等同 Wilcoxon
无序分类、2×2卡方 + Yates 校正correct = TRUE与未校正卡方有差异
无序分类、r×c卡方(无校正)无期望频数过小时不可靠
稀疏表Fisher 精确检验exact需逐变量指定

需要强调的是,p 值本身不该是基线表的重点。现在越来越多的期刊要求基线表报告标准化均数差(SMD),因为它不受样本量影响,能真实反映组间差异的大小。tableone 里加smd = TRUE就会多出一列 SMD 列,连续变量和分类变量都支持。对于随机对照研究,通常只报 SMD 不报 p 值,因为 p 值在随机化之后本来就该是大的,报出来反而误导。观察性研究里两者都报也常见,但要在方法学里说清楚 SMD 的计算口径。

3. 分类变量的展开逻辑:n (%) 只是表象,水平与检验才是重点

3.1 showAllLevels 决定表格长度,因子水平顺序决定显示哪一行

分类变量的输出默认是每个水平一行,格式是n (%),百分比的分母是该组的有效样本数(不含缺失)。showAllLevels = TRUE是默认值,也就是说一个四分类变量会占四行。

表格一长,问题就来了。假设你有 30 个变量,其中 10 个是分类变量,平均 3 个水平,光分类变量就 30 行,加上连续变量和表头,一张表 50 行起步,投出去编辑部第一反应就是"能不能压缩成 supplementary"。

showAllLevels = FALSE会把每个分类变量压缩成一行,只保留一个水平。保留哪个水平,由因子水平的顺序决定,默认是第一个水平。这就引出一个非常关键的操作:因子水平的顺序要自己定,不能靠默认的字母序。比如性别编码成female/male,默认按字母序female在前,表格里那行就写female;你要是想让它显示male,就得relevel一下。再比如一个分级变量I/II/III/IV,默认按字母序排成I、II、III、IV,看着没问题,但如果你编码成1、2、3、4再转因子,字符序还是对的;一旦编码成A、B、C、D以外的形式,比如low、mid、high,字母序就乱了。

# 显式设定水平顺序,避免默认字母序带来的意外 df$sex <- factor(df$sex, levels = c("male", "female")) df$stage <- factor(df$stage, levels = c("I", "II", "III", "IV"), ordered = TRUE)

压缩成一行之后,读者能不能还原出另一水平的信息?能。因为同一行里有各组的 n 和百分比,用组内总人数减去这一水平的 n,就能算出另一水平。二分类变量这样处理完全没问题,三分类及以上就不行了,因为丢掉了两个水平的信息。所以showAllLevels = FALSE只适合二分类变量,多分类变量还得老老实实展开。

还有一个细节:多分类变量的展开行里,第一行往往不写变量名,下面几行留空,靠缩进对齐。这是排版惯例,导出到 Word 之后要检查缩进有没有丢。用print(tab, printToggle = FALSE)拿到矩阵再转数据框,缩进是靠行名里的空格实现的,转成 CSV 再导入 Word 时这些空格可能被吃掉,需要重新排版。

3.2 exact 参数与稀疏单元格:什么时候非用 Fisher 不可

卡方检验有个前提:期望频数不能太小。经验法则是所有单元格的期望频数不小于 5,或者期望频数小于 5 的单元格不超过 20%。不满足这个条件,卡方近似就不可靠,p 值会偏小,容易假阳性。

tableone 不会自动帮你检查这个前提,它只会在chisq.test内部发现期望频数过小时给一条警告,然后照算不误。警告信息在控制台一闪而过,很多人根本没注意。所以出表之后要专门看一眼有没有警告,有的话就得改。

exact参数就是干这个的。写法有两种:exact = TRUE表示所有分类变量都用 Fisher 精确检验;exact = c("stage", "grade")表示只对这些变量用。前者不推荐,因为 Fisher 精确检验在行列数多、样本量大的表上计算量爆炸,慢到能把你卡在那里。

# 只对稀疏的那几个变量用 Fisher print(tab, exact = c("stage", "complication"), showAllLevels = TRUE)

什么样的表算稀疏?举几个典型:罕见并发症的发生率(比如 3/120 对 1/118),某些亚型的分布(一个格子只有 2 例),小样本研究里的多分类变量。这几类在临床研究里太常见了,尤其是安全性分析,不良反应的发生率本来就低。

实际操作中的麻烦在于,你很难预判哪个变量稀疏,得先跑一遍看警告。可行的流程是:第一次跑不加exact,把所有警告记下来,第二次跑再针对性指定。或者更稳妥一点,写个小函数遍历所有分类变量,算一遍期望频数,把最小值小于 5 的变量名挑出来,直接塞进exact。

# 快速筛查哪些分类变量存在稀疏单元格 sparse_vars <- c("stage", "complication", "grade") for (v in sparse_vars) { tb <- table(df[[v]], df$group) expected <- suppressWarnings(chisq.test(tb)$expected) if (any(expected < 5)) { cat(v, "的最小期望频数:", round(min(expected), 2), "\n") } }

还有一个坑:Fisher 精确检验在 2×2 表上,R 报的是双侧 p 值,但在 r×c 表上,算法是基于超几何分布的精确枚举,计算时间和表的维度呈指数关系。超过 3×4 的表,加上样本量上千,可能要跑几分钟。所以exact = TRUE全局打开是自找麻烦。

最后说一个表述问题。用了 Fisher 之后,方法学里要写明"对于期望频数小于 5 的分类变量采用 Fisher 精确检验",而且要说明这个判断是基于数据观察后决定的,不是预先设定的。有些严格的审稿人会追问这一点,认为事后选择检验方法有偏倚风险。折中的做法是一开始就在统计分析计划里写清楚阈值规则,出表时严格按照规则执行。

3.3 categorical 参数把数值变量拉回分类阵营的三种场景

前面说过,变量类型由数据框里的列类型决定。但现实里经常遇到"形式上连续、实质上是分类"的变量,这时候就要用categorical参数强制改判。

第一种场景是编码型变量。性别存成 1/2,吸烟状态存成 0/1/2,心功能分级存成 1/2/3/4。这类变量如果直接进表,会得到"性别 1.48 ± 0.50"这种没法看的结果。解决办法有两个:一是在数据准备阶段就转成因子,用factorVars指定;二是在print阶段用categorical指定。我更推荐前者,因为因子化之后整个 R 环境里的建模、绘图都会按分类处理,不只是出表这一处。categorical更像是一个补丁,适合临时救场。

第二种场景是分组化的连续变量。年龄本来是连续的,但你想按<60/>=60两档来看,于是新建了一列age_cat存成 1/2。这一列如果不指定,会按连续变量印出1.35 ± 0.48。用categorical = "age_cat"就能正确显示。不过要注意,分组化的连续变量在建模时是有代价的,会损失信息,出表可以,建模要谨慎。

第三种场景是评分型变量。某些量表得分只有 0 到 5 这几个整数取值,按连续变量算均数在数学上没错,但解释起来很别扭——"平均得分 2.37 分"不如"得分 ≥3 者占 42%"直观。这种变量到底算连续还是分类,是个判断问题,没有标准答案。我的习惯是看取值个数:取值少于 6 个、且每个取值都有实际临床意义(比如疼痛评分 0-10 但只在 0、2、4、6、8 这几个值上有分布),按分类处理;取值多且接近连续,按连续处理。

# CreateTableOne 建表阶段用 factorVars(推荐) tab <- CreateTableOne( vars = c("age", "sex", "smoke", "stage", "sbp"), strata = "group", data = df, factorVars = c("sex", "smoke", "stage") ) # print 阶段用 categorical 打补丁 print(tab, categorical = c("smoke"))

两种方式的区别在于:factorVars影响的是建表时的内部处理,而categorical只在打印时改变呈现。如果变量同时出现在连续变量摘要和分类变量摘要里,行为会不一致。建议统一在建表阶段解决,categorical只用来处理偶尔漏掉的变量。

3.4 有序分类变量的第三条路:转数值 + nonnormal

有序分类变量是基线表里最麻烦的一类。典型例子是疾病分期(I/II/III/IV)、NYHA 心功能分级(1/2/3/4)、Likert 量表(很不同意到很同意)。它们既不是真正的连续变量(级别之间的距离不一定相等),也不是纯粹的无序分类变量(级别之间有顺序)。

按无序分类处理,会展开成四行,占篇幅,而且卡方检验完全没有利用顺序信息,检验效能低。按连续变量处理,算均数在数学上是可以的,但"平均分期 2.3 期"这种表述在临床上是没有意义的,审稿人也会质疑。

实际研究里最常见的折中方案是三条路:

第一条,仍然按无序分类处理,展开所有水平,用卡方检验。最保守,什么都不丢,就是占地方。小样本研究或者分期是主要暴露变量时,我倾向这条。

第二条,转成数值后用中位数和四分位数间距描述,组间比较用 Kruskal-Wallis。这正是nonnormal参数的用武之地。做法是先as.numeric()把因子转成数字,然后用nonnormal指定它,输出形式就变成了median [P25, P75],检验也自动变成秩和检验。

# 有序因子转数值,再按非正态连续变量处理 df$stage_num <- as.numeric(df$stage) tab <- CreateTableOne( vars = c("age", "stage_num"), strata = "group", data = df ) print(tab, nonnormal = c("stage_num"))

第三条,转成二分类(比如 III+IV 对 I+II),按二分类变量处理。适合临床上本来就有明确切点的场景,比如"晚期对早期"。损失信息最多,但解释最清楚。

第二和第三条路都要在表注里说明处理方式,不能悄悄做。我见过一份稿子把分期转成数值算了中位数,表注里一个字没提,审稿人直接问"分期为什么会有小数"。这一点务必注意。

注意:把有序因子转成as.numeric()得到的是因子内部的整数编码(1、2、3……),不是标签里的数字。如果你的水平是I/II/III/IV,转出来就是 1/2/3/4,正好对应;但如果水平是低/中/高,转出来也是 1/2/3,虽然数值本身没意义,但排序关系保住了,秩和检验仍然有效。要点是转之前必须确认levels的顺序正确,顺序错了,整个检验就错了。

4. 两类变量的灰色地带:误判、缺失与格式化

4.1 变量被自动判错时的排查顺序

表出来之后发现某个变量的形式不对,比如明明是分类变量却印了均数,或者明明是连续变量却印了百分比,排查是有固定顺序的。

第一步,查列类型。str(df)或者sapply(df, class)一秒钟就能看出来。数值型进了分类分支,说明你在factorVars里误加了它;因子型进了连续分支,说明它本该转数值但你没转,或者转的时候用了as.numeric(as.character(x))之外的方式导致编码错乱。

第二步,查因子水平。一个连续变量被当成分类,很可能是它其实是个因子,而你没注意。反过来,一个分类变量印成了均数,也可能是它被存成了数值。

第三步,查nonnormal有没有写错变量名。注意是写错,不是漏写。写错名字不会报错,只会静默失效,你以为指定了非正态,其实没有。这种错误最难查,因为脚本跑得通、表也出得来,就是结果不对。解决办法是在脚本里把变量名定义成常量,别在两处手打。

第四步,查分组变量的水平数。table(df$group)看一眼,如果有意外的空组或者 NA 组,检验结果会异常,甚至报错。

现象最可能的原因检查位置
分类变量印出均数存成了数值型,未进factorVarsstr(df)
连续变量印出 n (%)存成了因子,或误加进factorVarsstr(df)、factorVars
nonnormal指定无效变量名拼写不一致打印变量名逐字比对
p 值为 NA某组样本量为 0 或全缺失table(df$group, useNA = "ifany")
百分比合计不等于 100缺失值被排除在分母外missing = TRUE检查缺失

4.2 缺失值在表里的位置:includeNA 与 missing

基线表里的缺失值怎么处理,是个容易被忽略但审稿人很爱问的问题。默认情况下,连续变量的均数和中位数按有效样本算(剔除缺失),分类变量的百分比按该组的有效样本算。表里不会体现缺失了多少,读者以为数据是完整的。

print里加missing = TRUE,会给每个变量多出一列,显示缺失个数。这个功能建议在探索阶段一直开着,出正式表的时候再决定要不要保留。如果某个变量缺失率超过 10%,通常需要在正文里专门说明缺失机制和处理方式,光在表里显示个数是不够的。

CreateTableOne的includeNA参数是另一个思路:把缺失当成一个独立的分类水平。设成TRUE之后,分类变量的缺失会变成一行 "NA",参与卡方检验。这个做法在缺失本身有信息的时候是有意义的(比如"未检测"代表某种临床决策),但多数情况下不建议,因为把缺失当水平会稀释组间差异,而且方法学上不好解释。

# 探索阶段:显示缺失个数 print(tab, missing = TRUE, showAllLevels = TRUE) # 正式表:不显示缺失,但在正文交代缺失率 print(tab, missing = FALSE)

连续变量的缺失还有个隐藏问题:如果缺失比例高,均数和中位数的稳定性都会下降,此时更稳妥的做法是报告有效样本数。tableone 的输出里默认不显示每个变量的 n,需要靠print的test和missing配合才能间接推出来。真在意这个,可以用print(tab, printToggle = FALSE)拿到矩阵后自己加一列 n。

4.3 位数、千分位与变量标签的规范化

一张专业的基线表,位数是要统一的。经验规则是:均数比原始数据多保留一位小数,标准差比均数多保留一位;百分比保留一位小数;p 值保留三位小数,小于 0.001 时写<0.001。tableone 的默认值基本符合这个规则:contDigits = 2、catDigits = 1、pDigits = 3。

但默认值不是万能的。年龄通常保留整数或一位小数,血压保留整数,体重指数保留一位,实验室指标保留一到两位。一张表里混着这些,很难用一个全局参数搞定。实际做法有两类:一是接受全局设置,后期在 Word 里手工微调;二是导出成数据框后用格式化函数批量处理。

formatOptions参数主要用来处理大数字。样本量上万的研究里,计数会变成五六位数,加上千分位分隔符读起来舒服很多。

print(tab, contDigits = 1, catDigits = 1, pDigits = 3, formatOptions = list(big.mark = ","), varLabels = varLabels, explain = TRUE)

explain = TRUE会在表的下方自动生成一段缩写说明,解释表里出现的 SD、IQR、n、% 分别代表什么。这个功能省事,但生成的英文说明未必符合目标期刊的格式要求,多数情况下还是得自己重写表注。我一般开着它当参考,最终表注手写。

变量标签的规范化值得单独投入时间。所有变量名统一成英文全称加单位,单位用方括号或者逗号隔开,全文保持一致。中文期刊的表,变量名用中文,单位放括号里。这个工作看着琐碎,但一张表的专业度很大程度上就体现在这些地方。

5. 把参数串起来:一份可直接套用的基线表生成流程

5.1 模拟数据与变量清单

下面用一份完全模拟的数据把整套流程走一遍。数据里故意埋了几个典型的坑:性别存成数值、分期是有序因子、住院天数是明显的右偏分布、并发症是稀疏的二分类变量。

set.seed(20240501) n <- 240 df <- data.frame( group = factor(sample(c("A", "B"), n, replace = TRUE)), age = round(rnorm(n, 58, 11)), sex_code = sample(c(1, 2), n, replace = TRUE), # 数值编码的性别 bmi = round(rnorm(n, 24.3, 3.1), 1), sbp = round(rnorm(n, 132, 16)), stage = factor(sample(c("I", "II", "III", "IV"), n, replace = TRUE, prob = c(.3, .3, .25, .15)), levels = c("I", "II", "III", "IV"), ordered = TRUE), los = rpois(n, 7) + rpois(n, 2) * 3, # 右偏 cost = round(rlnorm(n, 9.2, 0.6)), # 强右偏 complication = rbinom(n, 1, 0.04) # 稀疏 ) # 关键一步:显式设定因子水平和标签 df$sex <- factor(df$sex_code, levels = c(1, 2), labels = c("Male", "Female")) df$complication <- factor(df$complication, levels = c(0, 1), labels = c("No", "Yes")) df$stage_num <- as.numeric(df$stage) # 有序变量转数值,供 nonnormal 使用 str(df)

跑完str之后确认:age、bmi、sbp是数值,sex、stage、complication是因子,los、cost是数值且明显偏态,sex_code是冗余列不要进表。这一步看着简单,但真正做研究时,进表的变量清单经常在数据准备阶段就定错了,后面全盘返工。

5.2 CreateTableOne 的参数组合

建表阶段只做两件事:指定变量清单和因子变量。正态性的判断留到打印阶段处理,这样建表函数可以复用。

library(tableone) vars <- c("age", "sex", "bmi", "sbp", "stage_num", "los", "cost", "complication") factorVars <- c("sex", "complication") tab <- CreateTableOne( vars = vars, strata = "group", data = df, factorVars = factorVars, addOverall = TRUE, smd = TRUE )

几个选择的理由说明一下。addOverall = TRUE加一列总体,方便读者看整体分布,多数期刊的表 1 都有这一列。smd = TRUE加标准化均数差列,现在越来越多的期刊推荐,尤其是随机分组的研究。factorVars里只放sex和complication,因为stage_num要按非正态连续变量处理,stage本身不进表(避免和stage_num重复)。

关于重复变量:同一个变量不要在表里出现两次,一份按连续、一份按分类。有人为了"信息全面"同时放stage和stage_num,表里两行说的是一回事,审稿人一定会指出来。

5.3 print 阶段的三次检查与导出

打印阶段的参数最多,我习惯分三次调用,每次只验证一件事,最后再合成一次。

第一次看类型和水平展开对不对:

print(tab, showAllLevels = TRUE, missing = TRUE)

重点看:sex有没有正确显示两行、complication是不是两行、stage_num是不是印成了中位数(这时候还没有指定nonnormal,它可能印成均数,正常)。missing = TRUE那几列顺便检查缺失情况。

第二次看正态判断和检验方法:

print(tab, nonnormal = c("los", "cost", "stage_num"), showAllLevels = TRUE, exact = c("complication"), smd = TRUE, pDigits = 3, contDigits = 1, catDigits = 1)

重点看:los、cost、stage_num三行是不是显示为median [P25, P75];complication用的是不是 Fisher;stage_num的 p 值是不是变了(说明秩和检验生效)。这一步如果出现 "Chi-squared approximation may be incorrect" 的警告,说明还有变量没进exact,回去补。

第三次出正式表并导出:

res <- print(tab, nonnormal = c("los", "cost", "stage_num"), exact = c("complication"), showAllLevels = TRUE, smd = TRUE, contDigits = 1, catDigits = 1, pDigits = 3, varLabels = varLabels, printToggle = FALSE, explain = FALSE) write.csv(res, "table1.csv", row.names = TRUE)

printToggle = FALSE让print只返回矩阵不打印到控制台,随后write.csv就能落盘。注意矩阵的行名里带着变量名,导出的 CSV 第一列是行名,转 Word 时用制表符粘贴比直接贴更整齐。

5.4 出表前的自检清单

表做完之后,我有一份固定的自查清单,逐条过一遍基本不会有遗漏。

检查项目的tableone 中实现
变量类型是否与业务含义一致防止 1.48±0.50 这类错误str、factorVars
每个连续变量的分布形态判断该用均数还是中位数自己画直方图,nonnormal
因子水平顺序决定showAllLevels=FALSE时显示哪一行factor(levels=)、relevel
稀疏单元格决定是否用 Fisher预筛期望频数,exact
缺失分布判断是否需要专门说明missing = TRUE
检验方法与方法学描述是否一致防止描述与实现脱节对照默认映射表
位数与单位是否统一提升可读性contDigits、catDigits、varLabels

按我自己这几年出表的经验,返工最多的不是统计方法选错,而是变量类型和因子水平这类看起来不起眼的基础问题。统计方法选错了,p 值不对劲,跑一遍敏感性分析就能发现;类型和水平错了,表照样能出来,数字看着也正常,只有懂行的人一眼才能看出别扭。所以每次出表之前,花五分钟按上面这张清单走一遍,比事后返工划算得多。

最后再补一个小技巧:把上面这套脚本封装成一个函数,变量清单和参数作为参数传进去。同一个研究换一批数据、换一个分组变量,改两行就能重新出表。我自己的项目里就是这么干的,主表、亚组表、敏感性分析的表共用一套代码,一致性有保障,也省了大量重复调试的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进