
做临床数据或者流行病学分析的人,迟早要面对一张叫“基线表”的东西,业内一般喊它 Table 1。这张表决定审稿人对整篇研究的第一印象,而 tableone 这个 R 包,基本上就是把这张表从 Excel 的手工活里解放出来的工具。前面聊过 CreateTableOne 的整体框架和数据准备,这一篇我只盯两个东西:连续型变量和分类变量。表面上看这是 tableone 里最基础的两类变量,实际上八成的坑都埋在这里:变量类型判断错、正态性选错、检验方法对不上、小数位数前后不一致、导出以后 p 值凭空消失。我见过不止一次因为一个 0/1 编码没有转成 factor,把二分类变量当成连续变量算了组均数,结果整张表被审稿人要求重做。这篇文章会把这套东西从头到尾拆一遍:默认规则是什么、参数怎么调、每一步为什么要这样做、做错的影响范围有多大,以及我自己踩过的那些具体的坑。1. 变量类型的底层判定逻辑1.1 为什么类型判定是 Table 1 的第一道坎在 tableone 的整个处理链路里,变量类型是唯一一个“后面改起来代价极高”的环节。原因很简单:类型决定了用哪个统计量、走哪种检验、输出什么格式,这三件事是串起来的。一个变量被判成连续型,它就会去算均数和标准差,组间比较走方差分析;被判成分类变量,它就会去算各组频数和百分比,组间比较走卡方。这两条路在代码里是两条完全独立的分支,不存在“算错了后面再补一下”的可能。更麻烦的是,R 不会主动告诉你它理解错了。你一列写0/1的性别编码,传进去照样能跑,输出里会出现一行“性别 0.48 ± 0.50”,看起来还挺像那么回事。只有当你对着表看,发现“性别”下面写的是均值而不是“男 152 (50.7%)”的时候,才会意识到出事了。如果这个错误没被发现,影响范围会一路延伸到结论层:基线均衡性判断、亚组分析、甚至后面的回归模型协变量设定,都建立在这个错误之上。所以我的习惯是,建表之前先单独做一张变量字典,把每个变量的类型、单位、是否分组变量、缺失数量、预期分布列清楚,再动 CreateTableOne。这一步多花十分钟,后面能省两小时。1.2 tableone 的自动判定规则与它的盲区CreateTableOne 内部对变量类型的判定逻辑大致是这样:数据框里已经是 factor 或者字符型的列,自动归为分类变量;数值型(numeric/integer)的列,默认全部归为连续型。这条规则本身没毛病,问题在于它太“信任”你的数据类型了。盲区主要有三个。第一个,数字编码的分类变量,比如性别0/1、分期1/2/3/4、有无某个症状0/1,只要没有转成 factor,一律被当成连续变量。这是最常见的坑,没有之一。第二个,看起来连续但实际离散的评分型变量,比如疼痛评分 0 到 10 分,取值只有 11 个,严格来说既可以当连续也可以当分类,tableone 一律按连续处理,这时候需要你自己决定并显式指定。第三个,字符型里的异常值,比如本该是Male/Female的列里混进了一个unknown或者空字符串,它会自动变成分类变量里的一个水平,悄悄多出一行。应对办法就是显式声明。CreateTableOne 里有个factorVars参数,把所有需要按分类处理的变量名塞进去,不管它原来是什么类型,tableone 都会强制转换。我现在的做法是:凡是分类变量,不管三七二十一全部列进factorVars,反正写全了不会有副作用,漏写一个就要出事。myVars - c(age, bmi, sbp, los, sex, stage, egfr) catVars - c(sex, stage) # 分类变量全部显式声明 tab - CreateTableOne( vars myVars, strata arm, data dt, factorVars catVars, addOverall TRUE )顺带说一句addOverall。默认是 FALSE,只有分组列;设成 TRUE 会多出一列 Total。投稿用的 Table 1 一般都要 Total 列,所以这个参数基本是必开的。但要注意,当 Total 列存在时,百分比的分母会变成全样本,写正文时不要把 Total 列的百分比和分组列的百分比混着引用。1.3 统计量选择的背后逻辑:为什么是均数而不是中位数连续变量的核心决策只有一条:报 mean ± SD 还是 median [Q1, Q3]。这不是审美问题,而是描述性统计的准确性问题。均数和标准差对偏态和离群值极度敏感,一个住院天数里有几个 90 天的极端病例,均数就会被拉高一大截,而中位数几乎不动。举个具体的例子。住院日这个变量,真实分布通常是右偏的,大部分人 5 到 10 天,少数人几十天。假设某组 100 人,90 人住了 7 天,10 人住了 40 天,均值会被拉到 10.3 天左右,而中位数稳稳地停在 7。你把这个均值写进 Table 1,读者会以为这组的“典型住院日”是 10 天,实际上绝大多数人是 7 天。这种失真在审稿意见里经常被点出来。反过来,如果变量本身接近正态,比如身高、部分实验室指标,用中位数就是浪费信息,还白白丢了精度。所以判断依据必须是分布形状,而不是“别人都这么写”。分类变量这边没有这层纠结,输出就是 n (%)。但有三个隐性选择需要你自己定:百分比的分母是列还是行(默认按列,即每组的样本量)、水平多的变量要不要合并稀疏类别、缺失值要不要作为单独一档展示。这三个选择都会改变表格的读法,后面第 3 节细讲。2. 连续型变量的参数实操细节2.1 正态性判断:三条路径互相校验判断正态这件事,我一般走三条路互相印证,谁都不单独作数。第一条是图形。直方图看整体形状,QQ 图看尾部偏离。QQ 图上点沿着对角线走就是接近正态,两端明显翘起来就是重尾或者偏态。这一条最直观,也最容易被忽略,很多人直接跳过图去跑检验。第二条是偏度系数。偏度绝对值小于 0.5 基本可以认为对称,0.5 到 1 之间算中度偏斜,超过 1 就比较明显了。这个指标不受样本量影响,比 p 值稳定得多。第三条才是 Shapiro-Wilk 检验。这里有个必须知道的陷阱:R 的shapiro.test()要求样本量在 3 到 5000 之间,超过 5000 直接报错。而且样本量一大,这个检验极其敏感,几百例以上几乎必然拒绝正态假设,哪怕 QQ 图看着挺直。所以它只在中小样本时作为参考,不能当判决书。check_shape - function(x, name ) { x - x[!is.na(x)] n - length(x) sw - if (n 3 n 5000) round(shapiro.test(x)$p.value, 4) else NA sk - round(mean((x - mean(x))^3) / sd(x)^3, 2) data.frame(var name, n n, shapiro_p sw, skew sk) } do.call(rbind, lapply(c(age, bmi, sbp, los), function(v) { check_shape(dt[[v]], v) }))实际决策的时候,我的经验是:图形和偏度说了算,Shapiro 只用来佐证。图形明显偏、偏度大于 1,就走非正态;图形挺直、偏度小于 0.5,就走正态;卡在中间的那批变量,优先看这个指标在临床上的解释习惯,比如住院费用、住院日这类公认右偏的指标,业内默认报中位数,跟着走就行,不用纠结。还有一个容易忘的点:分组变量的每一组都要单独看形状,不能只看总体。有时候总体看着对称,是因为两个分布相反的组被混在一起了,分开一看完全不是正态。稳妥的做法是按 strata 分组各画一遍 QQ 图。2.2 nonnormal 参数的正确用法确定哪些变量非正态之后,通过nonnormal参数告诉 tableone。这个参数可以传字符向量指定具体变量,也可以传 TRUE 表示所有连续变量都按非正态处理。print(tab, nonnormal c(sbp, los), smd TRUE)这个参数有一个非常重要但经常被忽略的连带效应:它不只改变输出格式,还改变检验方法。被nonnormal指定的变量,组间比较会自动从默认的近似检验切换成kruskal.test(秩和检验)。也就是说,这一个参数同时控制了“报什么统计量”和“用什么方法比”,两者是绑定的。这带来一个后果:如果你只是想换个呈现格式,但检验想保持参数检验,tableone 给不了这种组合,得自己算。我的建议是不要试图拆开,统计量和检验方法本来就该匹配:报了中位数和四分位数,却用方差分析出 p 值,逻辑上说不通,审稿人一眼就能看出来。还有一个细节:nonnormal传 TRUE 的时候要小心。有些变量其实是正态的,但你图省事全设成非正态,结果是白白损失了检验效能,尤其在小样本研究里,秩和检验的效能损失可能让本来显著的结果变得不显著。这个影响的量级在样本量 50 以下时比较明显,大样本基本可以忽略。2.3 小数位控制:digits、contDigits 与统一性问题小数位数这件事,看着是排版问题,实际是“表格专业度”的硬指标。同一张表里,年龄保留一位小数、BMI 保留两位、p 值保留三位,这种不一致会被审稿人直接点出来。tableone 里有两层控制。第一层是CreateTableOne()的digits参数,默认值是 1,它会同时作用在分类变量百分比和连续变量统计量上。很多人第一次跑完发现“为什么我的均数只有一位小数”,答案就在这里。第二层是print()里的contDigits、catDigits、pDigits三个参数,分别控制连续变量、分类变量百分比、p 值的小数位,它们的优先级高于digits。# 推荐写法:在 print 里一次性统一,不依赖 CreateTableOne 的 digits print( tab, nonnormal c(sbp, los), contDigits 1, catDigits 1, pDigits 3, smd TRUE, missing TRUE )我的固定组合是contDigits 1、catDigits 1、pDigits 3。理由是这样:连续变量一位小数对临床指标足够(身高体重血压血糖都够),两位反而显得过度精确;百分比一位小数是期刊常规;p 值三位能区分 0.049 和 0.041,少于三位就分不出来了。有个特殊情况要单独说:p 值小于 0.001 的时候,tableone 会直接输出0.001,不会输出 0.000。这个处理是对的,但导出成 CSV 之后再进 Word 或者 Excel,号可能被当成公式符号吞掉。导出前检查一眼,或者导出后把这一列设成文本格式。2.4 检验方法的选择与手动干预连续变量的默认组间检验是oneway.test,也就是 Welch 校正的方差分析,它不要求各组方差齐性。这个默认选择其实挺讲究的:传统方差分析要求方差齐性,而真实数据里各组方差经常不等,尤其在样本量不平衡的时候。tableone 直接默认用了 Welch 版本,省掉了方差齐性检验这一步,统计上更稳健。如果你的研究设计需要传统方差分析(比如导师明确要求),可以通过testApprox传一个自定义函数:print(tab, testApprox list( age function(x, g) summary(aov(x ~ g))[[1]][[Pr(F)]][1] ))testApprox接受一个命名列表,名字是变量名,值是一个接受“数值向量 分组向量”的函数,返回 p 值。这个机制同样适用于分类变量,后面会讲。test参数则是更粗粒度的开关:传 FALSE 全部不算 p 值,传字符向量只算指定变量的 p 值。我通常在初稿阶段用test FALSE快速看数据结构,确认格式没问题之后再开检验。这样做的好处是初稿跑得快,而且能避免被一堆显著性干扰对表格结构本身的判断。2.5 偏态变量的两种转换输出除了直接报中位数,tableone 还内置了两种对偏态连续变量的处理方式:log和minMax。log会对变量取自然对数后再算几何均数和几何标准差,适合对数正态分布的指标,比如某些抗体滴度、病毒载量、部分炎症因子。输出的标签会变成 geo mean 和 geo SD,阅读的时候要提前跟合作者说明,不然容易被误读成普通均数。minMax更简单,就是在统计量后面追加[最小值, 最大值],用来展示数据的实际取值范围。这个在描述性研究里特别有用,读者能一眼看出有没有超出临床合理范围的异常值。print( tab, nonnormal los, log crp, minMax age, smd TRUE )需要提醒的是,log转换有个前提:变量必须全部为正。数据里有 0 或者负值的时候,取对数会得到-Inf或者NaN,tableone 不会明确报错,而是默默把这一行算成异常值。我第一次用的时候就中过这个招,一个炎症指标里有几例测出来是 0(低于检测下限),结果整行的几何均数变成了 0。处理办法是提前把这些值按检测下限的一半替换,或者在变量字典里就标注清楚这个变量不能做对数转换。2.6 单位与标签:让表格自己能说话tableone 的变量名直接来自数据框的列名,所以列名最好一开始就带上单位,比如age_y、bmi_kgm2、crp_mgl。这样输出到 CSV 之后不用再手工补单位,省一道容易出错的工序。如果必须在 R 里改标签,可以在print()之后对返回的矩阵操作。前提是用printToggle FALSE,让 print 不直接输出而是返回一个字符矩阵:mat - print(tab, nonnormal los, printToggle FALSE, quote FALSE) rownames(mat)[rownames(mat) age_y] - Age, years这里quote FALSE是关键。默认quote TRUE会给每个单元格加上双引号,导出 CSV 用 Excel 打开时满屏引号,非常难看。设成 FALSE 之后干净很多,但要注意单元格里如果本身含有逗号,CSV 还是会加引号包起来,这是 CSV 格式本身的规则,不是 tableone 的问题。3. 分类变量的处理与展示细节3.1 levels 顺序决定表格长相分类变量在 tableone 里的展示顺序,完全由 factor 的 levels 决定。字符型列会被自动转成 factor,转换时按字母顺序排列,这个默认行为经常制造麻烦。最典型的是“是/否”这类二元变量。如果原始数据里是中文“是”和“否”,字母序或者编码序可能把“是”排在前面,于是表格里第一行是“是”,第二行是“否”。这在临床上很反直觉,读者习惯先看到“否”或参照组。更麻烦的是多水平变量,比如疾病分期 I/II/III/IV,如果按字符排序会变成 I、II、III、IV(运气好),但如果写成 1/2/3/4 的字符,排序会变成 1、2、3、4,一旦变成两位数,比如 1、2、10、11,排序就乱了。所以我的做法永远是:建表前显式设定 levels。dt$sex - factor(dt$sex, levels c(Male, Female)) dt$stage - factor( dt$stage, levels c(I, II, III, IV), labels c(Stage I, Stage II, Stage III, Stage IV) )这里levels和labels分开写的好处是:levels 保持原始编码用于匹配,labels 输出成人类可读的文本。一旦设了 labels,后面再按原始值筛选数据就会匹配不上,所以要么保留一份原始列,要么用 levels 而不动 labels。我一般是前者,多留一列原始值,后面做亚组分析的时候方便。还有一点容易被忽略:factorVars强制转换的时候,如果原列是字符型,tableone 内部会用它自己的顺序,不一定等于你设的 levels。所以优先在数据框里就把 factor 定好,而不是依赖factorVars去转。factorVars更适合处理“数值型但需要当分类”的情况。3.2 卡方、Fisher 与精确检验的取舍分类变量的默认检验是chisq.test。这里有个细节值得单独拎出来:tableone 内部调用卡方检验时,对 2×2 表默认不做连续性校正。而 R 的chisq.test()函数本身对 2×2 表默认是correct TRUE,也就是做 Yates 连续性校正。这两者结果会有差异,尤其是期望频数不大的时候。这个差异带来的直接后果是:你用 tableone 算出来的 p 值,和用 SPSS 或者手工chisq.test()算出来的对不上。我第一次遇到的时候排查了半天,以为是数据错了,最后才发现是校正开关的问题。想让结果和手工卡方一致,用argsApprox把参数传进去:print(tab, argsApprox list(correct TRUE))什么时候该用精确检验?判断标准是期望频数,不是样本量。经验规则是:2×2 表里如果有任何一个格子的期望频数小于 5,或者总样本量小于 40,就该考虑 Fisher 精确检验。多行多列表的规则更宽松一些,一般要求期望频数小于 5 的格子不超过 20%。exact参数的用法和nonnormal类似,可以传字符向量指定变量,也可以传 TRUE 全部用精确检验:print(tab, exact c(stage), smd TRUE)传 TRUE 的时候要掂量一下。Fisher 精确检验在多水平、大样本的情况下计算量会明显上升,几十个变量全开会卡住。而且精确检验本身偏保守,p 值通常比卡方大,全部替换可能让本来边缘显著的变量变得不显著。我的做法是只对稀疏的那几个变量单独指定,不做全局替换。summary()可以帮你核对到底用了哪种检验:summary(tab)输出里会列出每个变量实际使用的检验方法名,以及是否触发了精确检验的提示。这一步我强烈建议在定稿前跑一遍,肉眼确认每个变量走的都是你预期的那条路。因为nonnormal和exact这些参数一旦漏传或者传错名字,程序不会报错,只会静默地按默认方法算,你不主动核对根本发现不了。3.3 多水平变量的展示与 p 值归属多水平分类变量在表格里是展开成多行的,每个水平一行,变量名单独占第一行。p 值只出现在变量名那一行,后面各水平行的 p 值位置是空的。这是 tableone 的标准排版,符合期刊要求,但第一次看的人容易误以为“只有第一行有统计学意义”。实际含义是:那个 p 值对应的是整个变量的组间差异,也就是这张 r×c 表的整体检验结果,不是第一水平和其他组的比较。写正文的时候要表述成“不同分期间的组间分布差异有统计学意义(p 0.012)”,而不是“Stage I 组的 p 值为 0.012”。水平特别多的变量,比如十几个诊断分型、几十个科室编码,展开之后表格会变得很长,可读性直线下降。这种情况有两个处理方向:一是合并稀疏类别,把占比小于 5% 的水平归到“其他”;二是用cramVars把变量压缩成一行显示。print(tab, cramVars c(stage), smd TRUE)压缩之后会变成一行,展示各组的总体分布,不再逐水平展开。适合放在正文表格里,详细的分水平数据放到附录。这个参数我一般在变量水平超过 5 个的时候才用,水平少的变量展开更清楚。3.4 缺失值的三种处理策略分类变量的缺失值处理比连续变量复杂,因为缺失本身可能携带信息。比如“吸烟状态”缺失的那批人,可能恰好是不愿意填问卷的重度吸烟者,直接删除会引入偏倚。tableone 提供了几个开关。missing TRUE在表格底部追加一行,报告每个变量的缺失例数;includeNA TRUE则把缺失当成分类变量的一个独立水平,在表格里显示成NA那一档。tab2 - CreateTableOne( vars myVars, strata arm, data dt, factorVars catVars, includeNA TRUE, addOverall TRUE )这两种策略的适用场景不一样。做描述性统计、想让读者知道缺失规模的时候用missing TRUE;怀疑缺失非随机、需要把缺失作为一个信息类别展示的时候用includeNA TRUE。后者在表格里会多出一行NA,审稿人可能会问,所以用之前最好在方法学部分交代一句。还有第三种策略是多重插补,但那已经超出 tableone 的范围了,属于建模阶段的事。Table 1 阶段能做的就是如实报告缺失,不要偷偷删。连续变量的缺失处理简单一些:默认按完整案例分析,均数和标准差都在非缺失值上算。missing TRUE同样会报告缺失数。这里要留个心:如果某个连续变量缺失率超过 20%,只看完整案例的均数可能会有偏,最好在表注里说明缺失比例,或者做一次敏感性分析比较一下完整案例和全样本的差异。4. 完整实操:从原始数据到可投稿的表格4.1 造一份带坑的模拟数据为了把前面讲的坑都覆盖到,我造一份刻意带了几个典型问题的模拟数据:一个 0/1 编码的性别、一个右偏的住院日、一个含缺失的分类变量。set.seed(2024) n - 320 dt - data.frame( age round(rnorm(n, 62, 9)), bmi round(rnorm(n, 24.5, 3.2), 1), sbp round(exp(rnorm(n, log(132), 0.13))), los round(rlnorm(n, log(7), 0.65), 1), crp round(exp(rnorm(n, log(4.2), 0.8)), 2), sex01 sample(c(0, 1), n, TRUE), # 坑一:数字编码 stage sample(c(I, II, III, IV), n, TRUE, prob c(0.35, 0.30, 0.25, 0.10)), smoke sample(c(Never, Former, Current, NA), n, TRUE, prob c(0.45, 0.25, 0.22, 0.08)), # 坑二:含缺失 arm factor(sample(c(A, B), n, TRUE), levels c(A, B)) ) # 坑三:住院日在 B 组更偏,模拟组间分布形状不一致 dt$los[dt$arm B] - round(dt$los[dt$arm B] * 1.4, 1)三个坑对应三个处理动作:性别转 factor 并设 levels、吸烟状态决定是否includeNA、住院日按非正态处理。这份数据跑出来的结果不会特别漂亮,但正好用来演示排查过程。4.2 先修数据,再建表dt$sex - factor(dt$sex01, levels c(0, 1), labels c(Male, Female)) dt$stage - factor(dt$stage, levels c(I, II, III, IV)) dt$smoke - factor(dt$smoke, levels c(Never, Former, Current)) myVars - c(age, bmi, sbp, los, crp, sex, stage, smoke) catVars - c(sex, stage, smoke) tab - CreateTableOne( vars myVars, strata arm, data dt, factorVars catVars, addOverall TRUE )注意sex01我保留在数据框里没删,后面做敏感性分析或者核对编码的时候还能用上。这种“原始列 派生列”并存的做法,在数据清洗阶段很值得坚持,多占不了多少内存。4.3 参数组合与输出核对print( tab, nonnormal c(sbp, los, crp), log NULL, exact c(stage), smd TRUE, missing TRUE, contDigits 1, catDigits 1, pDigits 3, showAllLevels TRUE )跑完第一件事不是看 p 值,而是核对三件事:变量数量对不对、类型对不对、缺失数对不对。变量数少了说明vars漏写,类型不对说明factorVars漏了,缺失数异常说明数据里有非预期编码。这三项都对上,再去看统计量。第二件事看 SMD 列。SMD 是标准化均数差,它的好处是不受样本量影响。p 值在大样本时几乎必然显著,几百例的基线表里一堆 p 0.05 是常态,这时候看 p 值判断均衡性意义不大。业内比较通行的经验阈值是SMD 绝对值小于 0.1 视为组间均衡,0.1 到 0.2 之间算轻度不平衡,超过 0.2 就要在讨论部分交代或者做倾向性评分校正。所以我的核对顺序是:先看 SMD 找不平衡的变量,再用 p 值做辅助参考,结论以 SMD 为主。这个顺序和很多人的习惯相反,但在观察性研究里更站得住脚。4.4 导出与二次加工mat - print( tab, nonnormal c(sbp, los, crp), exact c(stage), smd TRUE, missing TRUE, contDigits 1, catDigits 1, pDigits 3, quote FALSE, noSpaces TRUE, printToggle FALSE ) write.csv(mat, file table1_raw.csv, row.names TRUE)noSpaces TRUE会去掉用来对齐的多余空格,这在终端看没啥感觉,导出 CSV 的时候差别很大:不加这个参数,单元格前后会带一堆空格,Excel 里打开全是空隙,还得手工清一遍。导出的 CSV 里第一列是变量名(包括分类变量展开后的水平名),后面是各组的统计量、p 值和 SMD。进 Word 之后再做三件事:删掉不需要的列(比如 SMD 如果期刊不要求可以移除)、把变量名换成正式的中英文标签、在表注里说明统计量格式和检验方法。表注这一段经常被写得很敷衍,其实它是审稿人重点看的地方。我的模板大致是这样:连续变量正态分布报均数(标准差),组间比较用 t 检验或方差分析;非正态报中位数(四分位数间距),用秩和检验;分类变量报例数(百分比),用卡方检验,期望频数小于 5 时用 Fisher 精确检验;缺失值单列报告。这一段写清楚,后面能省掉不少返修意见。5. 常见问题速查与避坑心得5.1 高频问题排查表下面这张表是我这几年攒下来的,基本上覆盖了 tableone 在连续变量和分类变量上会遇到的绝大多数问题。遇到报错或者结果不对,先对一遍表,比盲猜快得多。现象最可能的原因处理办法二分类变量输出“均值 ± 标准差”数值编码未转 factor,也没进factorVars加进factorVars或在数据框里显式 factor 化连续变量只保留一位小数CreateTableOne的digits默认 1 生效在print()里显式指定contDigitsp 值全是 NA某分组样本量为 0、变量全缺失、或 strata 含 NA检查table(dt$arm)和缺失分布分类水平顺序混乱字符型自动按字母序转 factor建表前显式设levels2×2 表 p 值与 SPSS 不一致是否做 Yates 连续性校正的差异用argsApprox list(correct TRUE)对齐期望频数小的格子仍走卡方未指定exact对稀疏变量单独传exact 变量名对数转换后出现 0 或 NaN变量含 0 或负值提前按检测下限处理,或放弃log导出 CSV 每格带双引号quote默认为 TRUE设quote FALSE表格行数比预期多缺失被当作独立水平,或水平里有异常字符串检查includeNA与唯一值某变量整行消失该变量在所有组中分布完全一致检查dropEqual设置或数据本身表里最后一条值得多说一句。当某个分类变量在所有分组里的分布完全一样时,tableone 默认会做压缩或提示,不会原样展开。这本来是个贴心设计,但如果你刚好在做一张需要完整展示的附表,就会觉得“变量怎么没了”。遇到这种情况先确认数据是不是真的没有变异,再去看dropEqual的设置,不要一上来就怀疑代码写错了。5.2 参数传错不报错,这是最大的风险tableone 最让人头疼的地方不是它功能少,而是它对错误极其宽容。变量名拼错、参数传了不认识的值、nonnormal里写了一个不存在的变量,这些情况大多不会抛异常,而是静默地按默认逻辑走下去。你拿到一张看起来很正常、实际上方法全错的表。我踩过最典型的一次,是在nonnormal里写了一个旧变量名(数据清洗时改过列名,忘了同步),结果那个严重右偏的指标被当成正态处理,报了均数,还走了方差分析。因为输出格式看着很正常,我一直没发现,直到合作者问“这个指标的均数怎么比中位数高这么多”,才回头查出来。从那以后我养成了两个习惯。第一个是定稿前必跑summary(tab),逐行核对每个变量实际用的检验方法,肉眼确认没有意外。第二个是变量名统一从字符向量里取,不要在多个地方手写字符串:num_vars - c(age, bmi, sbp, los, crp) skewed - c(sbp, los, crp) stopifnot(all(skewed %in% num_vars)) # 提前拦截拼写错误stopifnot这一行成本极低,但能挡住大部分因为改名、拼写导致的静默错误。类似的检查我还加了一条,校验catVars是否都在myVars里,防止出现“声明了但没纳入”的变量。5.3 几个不那么显然的经验第一条,关于连续变量和分类变量的边界。有些变量天生模糊,比如教育年限(0 到 20 年)、疼痛评分(0 到 10)、子女数量。我的判断标准是:看这个变量的取值是否代表“程度”还是“类别”。教育年限代表受教育程度,按连续处理更合理,能保留信息量;疼痛评分虽然是整数,但临床上常按轻中重分组讨论,这时候转分类反而更贴合解读习惯。没有绝对对错,但一定要在方法学部分说明你的处理方式,别让审稿人来猜。第二条,关于showAllLevels。默认是 TRUE,所有水平都展开。但有些期刊要求表格紧凑,只展示关键水平,这时候设成 FALSE,表格会短很多。代价是丢失了各水平的完整分布,只保留汇总信息。如果研究重点在某个特定水平(比如“当前吸烟” vs 其他),那展开所有水平反而不利于读者抓重点,可以考虑先合并再建表。第三条,关于结果的可复现性。tableone 的输出依赖随机种子吗?建表过程本身不涉及随机,但如果你在建表前做过任何抽样、插补、或者用了随机森林之类的方法生成派生变量,一定要在脚本开头固定set.seed()。我见过因为没固定种子,同一份代码跑两次表格数字略有差异的情况,排查起来非常痛苦。第四条,关于连续变量的极端值。tableone 不会做任何截断或者缩尾处理,原始数据里有什么就报什么。如果某个指标存在明显的录入错误(比如身高 175 cm 被录成 1750),均数和标准差会被严重污染,而中位数和四分位数受影响较小。这也是偏态变量优先报中位数的一个附加理由。发现这类问题,应该在数据清洗阶段处理,而不是指望 tableone 帮你兜底。最后分享一个我在实际项目里反复用到的小技巧:把整个建表流程封装成一个函数,参数就三个——数据、变量列表、分组变量名。这样换数据集、换分组、做亚组分析的时候,一行调用就能出表,不用每次重写一遍CreateTableOne加print的组合。亚组分析尤其受用,按科室、按中心、按年龄段分组各跑一遍,格式完全统一,不会出现主表和附表小数位数不一致这种尴尬。函数里把nonnormal、exact这些需要按变量指定的参数做成可选列表传进去,默认值走最常见的那套组合,用的时候只覆盖需要改的部分,维护成本很低。