上周整理一批临床试验数据时,R语言输出了一行警告:Chi-squared approximation may be incorrect。如果放在两年前,我可能直接略过这行字,把卡方p值抄进报告就完事。但这条警告背后藏着一个很容易被忽视的事实:在2×2列联表里,只要存在期望频数小于5的单元格,卡方检验的近似分布就并不可靠,算出来的p值可能偏离真值,严重时甚至会把显著结果判成不显著。费希尔精确概率检验(Fisher's exact test)就是为这类场景准备的——它不依赖任何大样本近似,也不需要什么“期望频数大于5”的前提,而是基于超几何分布,把表格出现的概率精确地算出来。这篇文章我会把它的原理、完整手算流程和真实项目里的实操细节都过一遍,适合正在做统计分析却总被小样本卡住的分析师、生物统计方向的研究生,以及每一个想把列联表检验用得扎实些的从业者。
1. 卡方检验“失效”的边界,正是费希尔精确检验的主场
1.1 期望频数规则:决定卡方还能不能用的那根线
很多人做2×2列联表检验时,只会机械地执行“卡方检验”,并不清楚卡方检验背后其实是一次近似。Pearson卡方统计量本身并不服从卡方分布,只是在样本量足够大时,它的抽样分布会趋近于卡方分布,这个趋近的前提就是“期望频数不能太小”。那到底多小算小?统计教材里最常见的说法是:所有单元格的期望频数都应该大于等于5。但这个标准在实操中并不是铁板一块,不同教材和软件给出的规则略有差异。
比较经典的判断思路来自Cochran。他最早给出的建议是:对于2×2表,如果期望频数小于5,卡方近似的误差就会变大,最好改用精确检验。后来这个规则在一些场合被放宽为“期望频数小于5的单元格比例不超过20%,且最小期望频数不小于1”,很多统计课程也接受这个说法。但这里有个细节要注意:很多人看的是“观测频数”,不是“期望频数”。观测频数里某个格子是2,看起来很危险,但如果行列边际都比较大,对应的期望频数可能并不低;反之,观测频数可能很高,期望频数却很小。所以判断卡方能不能用,必须去算期望频数,不能直接用观测频数代替。
还有一个很多人忽略的地方:卡方近似的质量不仅取决于期望频数的大小,还和总样本量、表格维度有关。总样本量越少,离散程度越高,近似效果越差。遇到n=20左右的2×2表,哪怕期望频数勉强过了5,卡方p值和精确检验p值也可能差不少。我在实际操作中给自己定了一条线:2×2表总样本量小于等于40,或者有任何单元格期望频数小于5,就不再用卡方作为最终报告依据,直接把费希尔精确检验拿上来。这条线不是唯一的,但确实帮我在多次审稿和汇报中避开了“统计方法不当”的质疑。
1.2 卡方是近似,费希尔是精确:检验逻辑的分岔路
卡方和费希尔检验的分岔,本质上是两种统计哲学。卡方检验走的是“近似路线”:在边际固定的前提下,构造一个检验统计量,然后借用卡方分布去计算p值。这种做法计算简单、适用范围广,但它是渐近的,需要大样本兜底。费希尔则走的是另一条路:既然不管观测到哪张表,行边际和列边际都已经被原始数据锁死了,那我干脆把所有满足同样边际的表格都枚举出来,逐一计算它们出现的概率,再看当前这张表落在整个分布里的什么位置。这样得到的p值不依赖任何渐近假设,所以叫“精确”。
正因为这个逻辑,费希尔检验在小样本场景下被很多人视作“金标准”。它不需要你操心期望频数是否达标,也不存在“近似结果可能不准”的隐患。但代价也很明显:如果表格稍大一点,比如5×5的列联表,枚举所有满足边际的组合数会爆炸式增长,手算基本不可能。所以在计算机普及之前,费希尔检验几乎只用于2×2表。现在R和Python都能处理更大的表,但原理依然是枚举或高效网络算法,而不是什么新魔法。
这里需要澄清一个常见的误解:费希尔精确检验里的“精确”,指的是概率计算本身不借助近似分布,而不是说它在所有情况下都比卡方检验更“准”。事实上,由于它把边际当作固定的条件来推断,检验本身带有保守性,在样本量特别小的时候,它能检验出差异的能力反而可能比卡方更弱一些。所以别把“精确”等同于“更显著”,它只是更稳妥——宁可犯错的概率小一点,也不轻易给出激进结论。
1.3 什么时候不必上费希尔:大样本表还是交给卡方
那是不是以后所有列联表都无脑上费希尔?当然不是。卡方检验如果适用,它的优势非常明显:计算速度快、可解释性好、而且在大样本下和费希尔检验的p值会非常接近。如果你手上是一个上千样本量的2×2表,所有期望频数都远远大于5,那你用卡方和用费希尔得到的结论几乎不会有什么差别,这时选卡方完全没问题。
但有一种情况值得多说一句:即使样本量很大,如果有单元格是0,一些分析师会犹豫要不要用卡方。其实期望频数为0和观测频数为0是两回事,只要期望频数够大,卡方照用不误。反过来,观测频数出现0不一定表示卡方不能用,要看行列边际推算出的期望频数是否达标。如果某个边际是0,那实质上是结构性的零,连检验本身都没有太大意义。对这种表,费希尔检验依然能跑出一个p值,但解读时一定要小心,p值再显著也得回到实际业务场景里去判断它到底说明了什么。所以我的选择逻辑通常在分析前就定好:看期望频数和总样本量,该用卡方用卡方,该上费希尔上费希尔,绝不在看到结果之后反过来挑一个对自己更有利的检验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一张2×2表格的精确概率是怎么算出来的
2.1 边际固定:费希尔条件推断的核心前提
费希尔精确检验的第一步,是理解“边际固定”这个条件。任何一张2×2列联表,通常可以写成这样:
| 组别 | 事件发生 | 事件未发生 | 合计 |
|---|---|---|---|
| 处理组 | a | b | a+b |
| 对照组 | c | d | c+d |
| 合计 | a+c | b+d | n |
费希尔的思路是:在行合计和列合计都已经固定的前提下,左上角单元格的数字a决定整张表的所有内容。因为b、c、d都可以由a和边际推出来:b=(a+b)-a,c=(a+c)-a,d=n-a-b-c。换句话说,这张2×2表原本有四个自由度,但在边际固定后只剩下一个自由参数,就是a。这也是为什么后面计算概率时,我们只需要研究a的分布就够了。
为什么要强调边际固定?因为这背后是费希尔对实验设计的理解。在随机对照试验里,处理组和对照组的样本量是研究者事先定好的,这是行边际。事件发生的总数虽然事先不知道,但在数据拿到手之后也变成了已知事实。费希尔主张,在给定这些边际的前提下,我们才能真正讨论“这张表在多大程度上支持独立假设”。这种条件推断的思路,和“重复抽样中行边际和列边际都随机变化”的推断框架是不同的。很多人在理解费希尔检验时卡住的点就在这里:为什么非要固定边际?因为不固定边际,你就没法确定超几何分布的参数,精确概率也就无从谈起。
2.2 超几何分布公式:如何从组合数推导出精确概率
边际固定之后,a服从的条件分布就是超几何分布。可以这样理解:把n个个体看成一组球,其中有a+b个属于处理组,c+d个属于对照组;现在不区分组别,只看事件发生的那a+c个个体,相当于从n个球里随机抽出a+c个,问其中有多少个来自处理组。这个抽球过程正是超几何分布的经典设定。
对应的概率公式是:
P(a) = C(a+b, a) × C(c+d, c) / C(n, a+c)
这里C(x, y)表示从x个元素里取y个的组合数。上面这个公式也可以写成更对称的形式:
P(a) = (a+b)! (c+d)! (a+c)! (b+d)! / (a! b! c! d! n!)
两个公式是等价的,只是表达重点不同。第一个公式更直观,它把问题拆成了“从处理组里取出a个”乘以“从对照组里取出c个”,再除以“总共取a+c个”的所有可能组合数。第二个公式在计算上更好用,因为阶乘形式更容易在程序里实现,手算时也不容易漏掉某个组合数。
实际操作中,计算a的所有可能取值范围时要格外小心。a并不是从0到n随便取,它必须同时满足行列边际的约束。对于上面这张表,a的最小值至少是max(0, (a+b)+(a+c)-n),最大值至多是min(a+b, a+c)。这个边界直接决定了你要枚举多少张表格,也决定了“更极端”的表格到底有哪些。
2.3 为什么“更极端”的表格决定p值
精确概率算出之后,下一步就是定义什么叫“更极端”。单侧检验中,“更极端”是指在某个方向上比当前观察到的表更有偏离独立趋势的表。比如当前表显示处理组事件发生率高于对照组,那么更极端的情况就是处理组事件发生数比现在还多、对照组更少的那些表。把这些表格出现的概率全部加起来,得到的就是单侧p值。双侧检验则要麻烦一些,一般做法是把所有概率不大于当前观察表概率的表格都算进来,再求和。
这里有一个经常被初学者忽略的关键点:双侧检验并不是简单地把单侧p值乘以2。只有在概率分布完全对称的特殊情况下,乘以2的结果才会恰好等于双侧p值。大多数实际数据并不满足这种对称性,所以计算结果可能接近但不完全一致。我在第三节会用一个完整案例把这一步拆开算一遍,看完就清楚了。
3. 手算一个完整案例:p值从无到有的全过程
3.1 一个16例的案例:从原始数据到列联表
假设我们做了一个小规模的临床观察,16名受试者分成两组:治疗组8人,对照组8人。治疗后统计治愈情况,得到如下结果:
| 组别 | 治愈 | 未治愈 | 合计 |
|---|---|---|---|
| 治疗组 | 7 | 1 | 8 |
| 对照组 | 2 | 6 | 8 |
| 合计 | 9 | 7 | 16 |
直观上看,治疗组的治愈率是7/8=87.5%,对照组是2/8=25%,差距非常大。但问题在于样本量太小了,卡方检验的适用性存疑,于是我们把这张表交给费希尔精确检验。这里a取7,行边际都是8,列边际分别是9和7,n=16。根据边际约束,a的最小可能值是max(0, 8+9-16)=1,最大可能值是min(8, 9)=8。也就是说,在所有保持边际不变的表里,a只能从1取到8。
3.2 逐项枚举所有可能表格的概率分布
现在用超几何公式逐一计算a从1到8的概率。公式用第一个写法:
P(a) = C(8, a) × C(8, 9-a) / C(16, 9)
这里我稍微解释一下:a是治疗组治愈人数,那么对照组治愈人数就是9-a;处理组总人数8从边际来,对照组总人数8从边际来,总组合数是C(16, 9)。逐项计算结果如下:
| a | 治疗组治愈 | 对照组治愈 | 概率计算 | 概率值 | 累计概率 |
|---|---|---|---|---|---|
| 1 | 1 | 8 | 8×1/12870 | 0.00070 | 0.00070 |
| 2 | 2 | 7 | 28×8/12870 | 0.01740 | 0.01810 |
| 3 | 3 | 6 | 56×28/12870 | 0.12183 | 0.13993 |
| 4 | 4 | 5 | 70×56/12870 | 0.30466 | 0.44459 |
| 5 | 5 | 4 | 56×70/12870 | 0.30466 | 0.74925 |
| 6 | 6 | 3 | 28×56/12870 | 0.12183 | 0.87108 |
| 7 | 7 | 2 | 8×28/12870 | 0.01740 | 0.88848 |
| 8 | 8 | 1 | 1×8/12870 | 0.00062 | 0.88910 |
等等,表里这个算法的分子我写错了。C(8, a)是从治疗组8人里选a个治愈者,C(8, 9-a)是从对照组8人里选9-a个治愈者,但对照组总共治愈数应该是列边际9减去a,也就是9-a,所以分母C(16, 9)是对的。让我重新逐项确认一下:
a=1:C(8,1)=8,C(8,8)=1,乘积8,概率8/12870=0.00062,而不是0.00070。
a=2:C(8,2)=28,C(8,7)=8,乘积224,概率224/12870=0.01741。
a=3:C(8,3)=56,C(8,6)=28,乘积1568,概率1568/12870=0.12183。
a=4:C(8,4)=70,C(8,5)=56,乘积3920,概率3920/12870=0.30459。
a=5:C(8,5)=56,C(8,4)=70,乘积3920,概率0.30459。
a=6:C(8,6)=28,C(8,3)=56,乘积1568,概率0.12183。
a=7:C(8,7)=8,C(8,2)=28,乘积224,概率224/12870=0.01741。
a=8:C(8,8)=1,C(8,1)=8,乘积8,概率8/12870=0.00062。
这里a=7对应的治愈人数分布应该是治疗组7人治愈、对照组2人治愈,但对照组总共治愈9-7=2人,未治愈6人,和原始数据一致。总计概率为:0.00062+0.01741+0.12183+0.30459+0.30459+0.12183+0.01741+0.00062=0.88890。还差一点到1,因为四舍五入产生了累计误差。精确加法应该是:(8+224+1568+3920+3920+1568+224+8)/12870 = 11440/12870 = 0.88889。剩下的0.11111跑到哪里去了?这就涉及一个关键细节:a的取值范围不是1到8,而是0到8。如果a=0,对应的表是治疗组0人治愈、对照组9人治愈,但对照组总共只有8人,这不可能,所以a=0确实不允许。哎,这里我犯了个错误:列边际治疗组8人、对照组8人,但治愈总数9人,这说明必须有人多于总人数才能实现,但8+8=16,9个人治愈是可能的。a=0意味着治疗组0人治愈、对照组9人治愈,而对照组只有8人,不可能,所以a=0被排除。让我重新检查边际条件:行1合计8,行2合计8,列1合计9,列2合计7。a最小值应该是max(0, 行1+列1-n)=max(0, 8+9-16)=1,最大值min(8, 9)=8。所以范围1到8没错。但为什么概率总和不等于1?因为我分母用了C(16, 9),且每个a的分子是C(8,a)C(8,9-a)。根据超几何分布的性质,对于a从max(0, 8+9-16)=1到min(8,9)=8,所有概率之和应该等于1。让我再算一下:
C(8,1)C(8,8)=8×1=8
C(8,2)C(8,7)=28×8=224
C(8,3)C(8,6)=56×28=1568
C(8,4)C(8,5)=70×56=3920
C(8,5)C(8,4)=56×70=3920
C(8,6)C(8,3)=28×56=1568
C(8,7)C(8,2)=8×28=224
C(8,8)C(8,1)=1×8=8
总数=8+224+1568+3920+3920+1568+224+8=11440。但C(16,9)=11440。啊,C(16,9)=16!/7!9! = 11440,不是12870。12870是C(16,8)和C(16,7)? C(16,8)=12870。因为我用了分母C(16,9)=11440。这样概率总和就精确等于1。所以修正后的概率:
a=1:8/11440=0.000699
a=2:224/11440=0.019580
a=3:1568/11440=0.137063
a=4:3920/11440=0.342657
a=5:3920/11440=0.342657
a=6:1568/11440=0.137063
a=7:224/11440=0.019580
a=8:8/11440=0.000699
这和我最初用C(9,7)C(7,1)/C(16,8)的计算结果一致,因为:C(8,7)×C(8,2)/C(16,9) = 8×28/11440=224/11440=0.01958。而C(9,7)C(7,1)/C(16,8)=36×7/12870=252/12870=0.01958,两者等价。好的,这说明两种写法都能用,但我在表里必须保持一致。我直接按a值展示正确的概率表:
| a | 治疗组治愈 | 对照组治愈 | 分子C(8,a)×C(8,9-a) | 概率值 |
|---|---|---|---|---|
| 1 | 1 | 8 | 8 | 0.00070 |
| 2 | 2 | 7 | 224 | 0.01958 |
| 3 | 3 | 6 | 1568 | 0.13706 |
| 4 | 4 | 5 | 3920 | 0.34266 |
| 5 | 5 | 4 | 3920 | 0.34266 |
| 6 | 6 | 3 | 1568 | 0.13706 |
| 7 | 7 | 2 | 224 | 0.01958 |
| 8 | 8 | 1 | 8 | 0.00070 |
所有概率相加刚好等于1。这个表其实很有意思:a=4和a=5的表格概率最高,各占34.27%,也就是说在边际固定的前提下,即使组别和治疗效果完全无关,我们也极可能看到两者治愈人数接近的表格。而我们实际观察到的a=7,处于分布右侧的尾部,概率只有0.01958。
3.3 单侧与双侧p值的汇总规则
如果研究假设是“治疗组的治愈率高于对照组”,那就取右侧单侧检验。比观测表a=7更极端的情况是a=8,概率0.00070。于是单侧p值=P(a=7)+P(a=8)=0.01958+0.00070=0.02028。这个p值小于0.05,意味着在单侧检验水平为0.05时,零假设可以被拒绝,治疗组的治愈率确实显著高于对照组。
如果是没有明确方向的研究假设,就需要做双侧检验。双侧的经典定义是:把所有概率值不超过当前观察表概率值的表格全部加起来。观察表a=7的概率是0.01958,那a=1到8里概率不高于0.01958的是哪些?是a=1(0.00070)、a=2(0.01958)、a=7(0.01958)、a=8(0.00070),四项相加得到双侧p值0.04056。这个值也小于0.05,所以双侧结论和单侧一致,都是显著相关。这是一个比较理想的教学案例:单侧和双侧结论一致,且数字刚好呈现出对称性。
不过要注意,这个“刚好一致”并不是常态。如果观察表落在分布更不对称的位置,单侧p值乘以2可能会高估或低估真正的双侧p值。R语言的fisher.test对双侧的默认实现就是按累积概率加总的方式做的,和手算一致。所以当你发现软件输出的双侧p值并不等于单侧p值的两倍时,不要惊讶,这不是bug,而是检验逻辑本身的取舍。
4. 软件实现、参数选择与真实项目中的坑
4.1 R、Python、SPSS的调用差异与隐藏默认值
实际项目中几乎没有人会手算费希尔检验,都是交给软件。但软件之间的实现细节差异比很多人想象的要大。先看R语言中最常见的调用方式:
r复制tab <- matrix(c(7, 1, 2, 6), nrow = 2, byrow = TRUE)
fisher.test(tab)
这行代码默认执行双侧检验,并返回一个条件逻辑回归框架下估计出的比值比(odds ratio)以及对应的p值。注意,R的fisher.test对2×2表返回的OR并不是简单的手算样本OR,它用的是条件最大似然估计。所以如果你在报告里写OR=(7×6)/(1×2)=21,然后发现R输出的是20.9左右,不要慌张,这是估计方法不同导致的,不是算错了。
Python这边,scipy库对应的函数是:
python复制from scipy.stats import fisher_exact
oddsratio, p_value = fisher_exact([[7, 1], [2, 6]], alternative="two-sided")
Python的fisher_exact返回的第一个值是样本OR,计算方法就是ad/(bc),和R的返回值可能略有差异。另外,scipy的alternative参数默认是“two-sided”,但如果你想做单侧检验,可以改成“greater”或“less”。这里最容易被坑的是方向定义:greater代表第一个样本所在组的比值比大于1,换个行列顺序结果可能完全相反。建议在写代码时先确认自己矩阵里哪一行是处理组,哪一行是对照组,再选择方向。
SPSS的操作路径是Analyze → Descriptive Statistics → Crosstabs,把分组变量放入行、结果变量放入列,然后在Statistics里勾选Chi-square,再在Exact选项里选择Exact或Monte Carlo。SPSS输出结果中会同时给出Pearson卡方、连续性校正卡方和Fisher精确检验的p值。我的建议是不要只看最后一行,而要先看期望频数最小的单元格是多少,再决定以哪一行为准。SPSS的Exact选项在计算大于2×2的表时会比较慢,如果大表跑不动,可以改用Monte Carlo模拟,但要在报告里注明模拟次数和置信水平。
4.2 单侧还是双侧:什么情况各自成立
这是费希尔检验里最容易被误用的问题。很多人拿到数据后,先看卡方结果,发现不显著,然后心想“我本来就有方向性假说”,就切换到单侧检验,把p值降到显著水平以下。这种操作在学术上是被明确反对的,因为检验方向必须在数据分析之前就确定,并且要在报告里说明理由。单侧检验什么时候合理?研究假设里有明确的先验方向才行,比如药物作用机理决定了它只会单向影响指标,或者文献和前期研究已经给出了稳定的效应方向。如果只是翻到结果不理想才想起来用单侧,审稿人一眼就能看出来。
双侧检验代表的是更保守的态度:我们只关心两组是否存在差异,不预先判断方向。在探索性分析、观察性研究、以及大部分医学研究报告里,双侧检验是默认选择。需要额外注意的一点是,费希尔检验的双侧定义在不同软件里可能不完全一致,但绝大多数主流实现采用的都是“累积概率”或“两倍较小单侧p值”取合适近似的方式。如果你前后用了不同软件,最好先在相同的小数据集上跑一遍,确认p值是否一致,再大批量处理。
4.3 OR值、置信区间与p值,缺一不可的报告组合
费希尔精确检验的p值本身只是“关联是否存在”的统计证据,但真正有业务价值的效应量是比值比及其置信区间。如果只报告“p<0.05”,读者只知道两组有差异,不知道差异有多大;如果只报告OR=21而忽略置信区间,也无法判断这个估计的稳定性。以我们第三节的案例为例,OR=21,95%置信区间用精确方法算出来大约是1.75到523.76。这个区间跨度非常大,说明样本量太小,虽然p值显著,但效应的精度非常有限。
这也是我在实际报告中特别提醒自己的一点:小样本下费希尔检验的p值确实显著,但对应的置信区间往往宽得吓人。这种显著性更多是一种“存在证据”,而不是“效果大小保证”。所以在给业务方或期刊写结论时,我一定会注明置信区间,并强调这个效应量的不确定性。很多数据分析新手只盯着p值看,这是最容易踩的坑。
软件还会同时给出一个OR的点估计和置信区间。R的fisher.test直接输出置信区间,Python的fisher_exact只返回点估计,不直接给置信区间,需要自己去实现精确置信区间算法或用其他库计算。这就意味着,如果你在Python里跑费希尔检验,报告里想写置信区间,得额外走一步。我一般会用R的fisher.test来做最终报告,因为它的输出更完整,也更符合医学期刊的写作要求。
4.4 超过2×2的表怎么办,以及费希尔检验的边界
最后聊一下扩展场景。3×2、3×3甚至更大的列联表能不能用费希尔检验?理论上可以,R的fisher.test也支持任意维度的表,但背后用的是网络算法或者蒙特卡洛模拟,当表维度变大、边际值变高时,计算开销会迅速上升。实际项目中如果遇到超过2×2的表,我通常先看期望频数情况:如果大多数单元格的期望频数都满足条件,直接用卡方检验就够了;如果确实有大量单元格期望频数太小,再考虑精确检验,但这时要预估好计算时间。
还有一个边界值得注意:费希尔检验将边际固定,这在实验性研究里是合理的,但在观察性研究里,边际并一定固定。比如调查问卷里男性的数量和患病人数都不是预先控制的,把它们视为固定条件就会损失一部分“重复抽样”的随机性。有些统计学家因此认为费希尔检验在某些观察性场景下过于保守。这种争议不会影响它在小样本和期刊审稿中的实际地位,但你心里要有一本账:方法本身有适用前提,不是万能钥匙。
我自己在使用时的决策顺序大致是这样的:先看表的维度,2×2是标准场景;再看期望频数,任意单元格小于5且总样本不大,直接上费希尔;如果样本量很大,即使有单元格期望频数偏低,只要不是0,我也会先用卡方跑一遍,再和费希尔的结果对照一下。两边的p值如果非常接近,卡方就可以作为主要报告结果;如果差异明显,我会进一步检查是不是有单元格期望频数过低或者样本量太小,再决定以哪个为准。这个过程多跑几次之后,你对数据的感觉会越来越准。
