跳至主要内容

Fisher 精确检验:在小样本中做出决策

学习在小样本条件下分析分类变量之间的关系。
· 9分钟 阅读

使用 AI 探索

组织每天都会依据客户行为、产品表现和运营波动来做决策。为确保这些决策建立在数据中真实存在的关联和模式之上,分析师常常使用统计检验。

尽管常见的统计检验适用于大型数据集,但企业并不总是有幸拥有大样本。事实上,许多决策不得不基于小样本、细分客户群或罕见事件。在这些情况下,传统检验(如卡方检验)可能力有不逮,要么给出误导性结果,要么缺乏足够精度,难以据此自信采取行动。

这正是 Fisher 精确检验发挥价值的地方。继续阅读,我将向您展示如何使用它。

什么是 Fisher 精确检验?

Fisher 精确检验用于在数据受限时评估分类变量之间的关联。它最常用于 2×2 列联表,计算在原假设(独立性)成立的前提下,观测到与已收集数据同等极端的数据的精确概率。

例如,在评估定向营销活动的有效性时,分析师可能会使用 Fisher 精确检验。若要比较电子邮件 与 社交媒体活动、且样本来自小众客户群,分析师可能会构建列联表并运行 Fisher 精确检验。或者,在质量控制场景中,分析师可以用该检验来处理事故报告和缺陷跟踪的挑战。某些失效类型仅发生数次时,Fisher 精确检验可用于判断如机器类型等因素是否与缺陷发生真正相关。

统计理论与数学基础

在形成对该检验重要性的直观理解后,我们可以加以形式化。Fisher 精确检验本质上是一种用于判断两个分类变量之间是否存在非随机关联的统计方法。它最常用于 2×2 列联表,比较两个群体在两个结局上的差异,例如“处理与对照”对应“改善与未改善”。

有趣的是,它的起源可追溯到统计学中广为人知的“女士品茶”实验。Ronald A. Fisher 爵士设计了一项实验,以检验 Muriel Bristol 博士是否能仅凭味觉分辨是一杯先倒牛奶还是先倒茶。作为实验的一部分,Fisher 准备了八杯茶,请她识别哪些是“先牛奶”与“先茶”。由于样本量很小,Fisher 提出了一个精确方法,用于判断正确猜测的模式是否可能偶然出现,这也为后来我们所知的 Fisher 精确检验奠定了基础。

在数据有限、不平衡或难以获取的情形(如临床研究)中,Fisher 检验有助于判断治疗与结局是否存在有意义的关联。生态学、流行病学、遗传学和质量控制等涉及稀疏数据的领域也依赖该检验。

Fisher 精确检验通过计算超几何分布的精确概率来评估独立性。对于 2×2 表:

 

B1 组

B2 组

合计

A1 组

a

b

a+b

A2 组

c

d

c+d

合计

a+c

b+d

n

在原假设(无关联)下观测到该精确配置的概率可通过超几何分布计算:

检验可以是单侧的,例如回答“在 B1 中 A1 是否比在 B2 中更可能出现?”,也可以是双侧的:“是否存在任何关联?”与大样本近似方法(如卡方)不同,Fisher 方法即便在期望频数很低时也能保证准确性。

假设与前提条件

要应用 Fisher 精确检验,需要满足以下条件。

  • 数据必须是分类变量
  • 以 2×2 列联表形式呈现
  • 观测彼此独立
  • 在原假设下行列边际(行列合计)固定

当表中任一格的期望计数极低(小于 5),或结果稀疏或严重不平衡时,优先选择 Fisher 检验。

在 Python 中进行 Fisher 精确检验

假设我们要检验某种药物是否能改善康复:

 

已康复

未康复

合计

治疗组

8

2

10

对照组

4

11

15

合计

12

13

25

原假设与备择假设如下:

  • H₀:治疗与康复独立
  • H₁:治疗提升康复率(单侧)

在 Python 中可以这样处理:

from scipy.stats import fisher_exact

# Construct contingency table
table = [[8, 2],
         [4, 11]]

oddsratio, p_value = fisher_exact(table, alternative='two-sided')
print("Odds Ratio:", oddsratio)
print("Two-sided p-value:", round(p_value, 2))

# One-sided test (treatment better)
_, p_value_one = fisher_exact(table, alternative='greater')
print("One-sided p-value:", round(p_value_one, 2))
Odds Ratio: 11.0
Two-sided p-value: 0.02
One-sided p-value: 0.01

较小的 p 值 0.02 表明治疗与康复之间存在真实关联。

现在,让我们使用公开的 NHANES 2017–2018 数据,考察“当前吸烟状态”与“是否曾被告知患有哮喘”在成人中的关联。

所用文件(均来自 NHANES 官方网站):

  • DEMO_J.XPT — 人口统计(年龄、性别、权重等)

  • SMQ_J.XPT — 吸烟 - 香烟使用(吸烟史与当前使用)

  • MCQ_J.XPT — 医疗状况(包括“是否曾被告知患有哮喘”)

我们将遵循以下步骤:

  1. 下载并载入数据集

  2. 创建二元变量:

    1. current_smoker(是/否)

    2. asthma_ever(是/否)

  3. 构建 2×2 列联表

  4. 运行 Fisher 精确检验(双侧与单侧)

  5. 计算比值比的 95% 置信区间

  6. 解释结果

注意:规范的 NHANES 分析需要使用抽样权重和复杂设计。

此处为教学目的忽略权重。让我们导入所需库。

# %% Imports
import pandas as pd
import numpy as np
from scipy.stats import fisher_exact
from statsmodels.stats.contingency_tables import Table2x2

请从 CDC 手动下载 XPT 文件,并将其放入本地 data/ 文件夹:

  • 访问:

    • 人口统计:2017–2018 人口统计数据(DEMO_J)

    • 医疗状况:MCQ_J

    • 吸烟 - 香烟使用:SMQ_J

  • 下载:

    • DEMO_J Data [XPT]

    • MCQ_J Data [XPT]

    • SMQ_J Data [XPT]

  • 将它们保存为:

    • data/DEMO_J.XPT

    • data/MCQ_J.XPT

    • data/SMQ_J.XPT

# Set paths or URLs here.
# If you've downloaded locally, point to the local paths:
DEMO_PATH = "data/DEMO_J.XPT"
MCQ_PATH  = "data/MCQ_J.XPT"
SMQ_PATH  = "data/SMQ_J.XPT"

# If you prefer to read directly from URLs, uncomment these instead:
# DEMO_PATH = "https://wwwn.cdc.gov/Nchs/NHanes/2017-2018/DEMO_J.XPT"
# MCQ_PATH  = "https://wwwn.cdc.gov/Nchs/NHanes/2017-2018/MCQ_J.XPT"
# SMQ_PATH  = "https://wwwn.cdc.gov/Nchs/NHanes/2017-2018/SMQ_J.XPT"

# Read SAS transport (.XPT) files
demo = pd.read_sas(DEMO_PATH, format="xport")
mcq  = pd.read_sas(MCQ_PATH,  format="xport")
smq  = pd.read_sas(SMQ_PATH,  format="xport")
demo.head()

mcq.head()

smq.head()

让我们查看这些 DataFrame 中的一些关键变量。此处 SEQN 为受访者 ID,可用作连接键。

# %% Inspect key variables

# SEQN is the respondent ID present in all three files.
# Check columns and pick what we need:
print("DEMO columns (subset):", demo.columns[:10])
print("MCQ columns (subset):", mcq.columns[:10])
print("SMQ columns (subset):", smq.columns[:10])
DEMO columns (subset): Index(['SEQN', 'SDDSRVYR', 'RIDSTATR', 'RIAGENDR', 'RIDAGEYR', 'RIDAGEMN',
       'RIDRETH1', 'RIDRETH3', 'RIDEXMON', 'RIDEXAGM'],
      dtype='object')
MCQ columns (subset): Index(['SEQN', 'MCQ010', 'MCQ025', 'MCQ035', 'MCQ040', 'MCQ050', 'AGQ030',
       'MCQ053', 'MCQ080', 'MCQ092'],
      dtype='object')
SMQ columns (subset): Index(['SEQN', 'SMQ020', 'SMD030', 'SMQ040', 'SMQ050Q', 'SMQ050U', 'SMD057',
       'SMQ078', 'SMD641', 'SMD650'],
      dtype='object')

让我们筛选所需列,并按年龄(大于等于 20 岁)过滤。

# %% Keep only necessary variables and filter adults

demo_sub = demo[["SEQN", "RIDAGEYR", "RIAGENDR"]].copy()
mcq_sub  = mcq[["SEQN", "MCQ010"]].copy()
smq_sub  = smq[["SEQN", "SMQ040"]].copy()

# Merge on SEQN (inner join: keep only participants present in all three files)
df = (
    demo_sub
    .merge(mcq_sub, on="SEQN", how="inner")
    .merge(smq_sub, on="SEQN", how="inner")
)
# Restrict to adults aged 20+
df = df[df["RIDAGEYR"] >= 20].copy()
df.head()

MCQ010 表示哮喘相关状况,其中 1 代表是,2 代表否。类似地,SMQ040 显示受访者当前是否吸烟。

# %% Recode variables into binary indicators

# 1. Asthma: MCQ010
#    1 = Yes, 2 = No, 7 = Refused, 9 = Don't know
#    We'll create asthma_ever: 1 = yes, 0 = no, NaN otherwise

df["asthma_ever"] = np.nan
df.loc[df["MCQ010"] == 1, "asthma_ever"] = 1
df.loc[df["MCQ010"] == 2, "asthma_ever"] = 0

# 2. Current smoker: SMQ040
#    1 = Every day, 2 = Some days, 3 = Not at all, 7/9 = missing
#    We'll create current_smoker: 1 = yes (1 or 2), 0 = no (3), NaN otherwise

df["current_smoker"] = np.nan
df.loc[df["SMQ040"].isin([1, 2]), "current_smoker"] = 1
df.loc[df["SMQ040"] == 3, "current_smoker"] = 0

# Drop rows with missing recodes
df_clean = df.dropna(subset=["asthma_ever", "current_smoker"]).copy()
df_clean[["RIDAGEYR", "MCQ010", "asthma_ever", "SMQ040", "current_smoker"]].head()

我们创建了新列 asthma_ever 和 current_smoker

让我们创建列联表,以便使用 Fisher 精确检验。

# %% Build a 2×2 contingency table

# Ensure ints
df_clean["asthma_ever"] = df_clean["asthma_ever"].astype(int)
df_clean["current_smoker"] = df_clean["current_smoker"].astype(int)

contingency = pd.crosstab(
    df_clean["current_smoker"],
    df_clean["asthma_ever"],
    rownames=["Current smoker (1=yes,0=no)"],
    colnames=["Ever told asthma (1=yes,0=no)"]
)
contingency

将该列联表转换为 NumPy 数组。

# %% Fisher's Exact Test

# Convert to 2x2 numpy array with explicit ordering:
# rows: [non-smoker, smoker] ; cols: [no asthma, asthma]
table = np.array([
    [contingency.loc[0, 0], contingency.loc[0, 1]],
    [contingency.loc[1, 0], contingency.loc[1, 1]]
])

table
array([[1102,  226],
       [ 832,  172]])

最后,运行双侧和单侧的 Fisher 精确检验。

# Run Fisher's exact test (two-sided and one-sided)
oddsratio, p_two_sided = fisher_exact(table, alternative="two-sided")
_, p_smoker_higher_asthma = fisher_exact(table, alternative="greater")
_, p_smoker_lower_asthma  = fisher_exact(table, alternative="less")

print("2x2 table (rows: non-smoker, smoker; cols: no asthma, asthma)")
print(table)
print()
print(f"Odds ratio (Fisher): {oddsratio:.3f}")
print(f"Two-sided p-value: {p_two_sided:.5f}")
print(f"One-sided p-value (H1: smokers have higher asthma odds): {p_smoker_higher_asthma:.5f}")
print(f"One-sided p-value (H1: smokers have lower asthma odds): {p_smoker_lower_asthma:.5f}")
2x2 table (rows: non-smoker, smoker; cols: no asthma, asthma)
[[1102  226]
 [ 832  172]]

Odds ratio (Fisher): 1.008
Two-sided p-value: 0.95571
One-sided p-value (H1: smokers have higher asthma odds): 0.49274
One-sided p-value (H1: smokers have lower asthma odds): 0.55144

较高的 p 值表明,在此样本中,没有证据显示当前吸烟与曾被诊断为哮喘之间存在统计学上显著的关联。

比值比约为 1,意味着当前吸烟者报告曾被诊断哮喘的几率与非吸烟者几乎相同。所见差异完全可能由随机因素造成。

与其他统计方法的比较

根据样本量、数据结构和假设条件,分析列联表关系可采用不同的统计检验:

Fisher 精确检验

最适合小样本及部分期望频数较低的情形。它提供精确的 p 值,因而非常可靠,但随着表规模变大,计算可能变慢。

卡方检验

大样本的标准选择。速度快、应用广泛、容易上手,但当期望计数低于 5 时,由于依赖近似而变得不准确。

Barnard 精确检验

在 2×2 表中较 Fisher 检验更有检验力的替代方案。它不假定固定边际,因此结果可能更敏感,但该方法在软件中的实现较少。

McNemar 检验

为配对或匹配的分类数据设计(如同一受试者治疗前后)。它考虑观测间的依赖性,但不能用于独立样本的情形。

方法

适用场景

优点

局限

Fisher 精确检验

小样本、期望计数低

精确 p 值

偏保守,大表计算慢

卡方检验

大样本

快速、广泛使用

当期望计数 < 5 时有偏

Barnard 精确检验

Fisher 的替代方案,检验力更强

不受固定边际限制

实现较少

McNemar 检验

配对分类数据

可处理依赖性

不用于独立样本

计算层面的考量

Fisher 精确检验的性能可能令人担忧,因为超出简单 2×2 列联表后,计算扩展性较差。尽管 Freeman–Halton 扩展允许将检验应用到 2×3 和 3×3 表,但随着表规模增大,计算成本会迅速上升。因此,在遗传学和临床研究等领域,往往需要在许多小而复杂的数据集上反复执行精确检验,此时高效算法与优化尤为重要。

结果解读与统计报告

在解释并报告 Fisher 精确检验结果时,重要的是报告精确 p 值、给出比值比及其 95% 置信区间,并明确说明进行的是单侧还是双侧检验。还应结合实际背景进行阐释,帮助读者理解发现的现实意义。例如,可以这样写:“Fisher 精确检验显示治疗与康复之间存在显著关联(OR=11.0,p=0.02,双侧)。”

扩展与进阶应用

Fisher 精确检验的扩展与高级应用有助于应对更复杂的数据情境,并在处理小样本或稀疏数据时提升准确性。Freeman–Halton 扩展可将检验应用到超出标准 2×2 格式的更大列联表,如 2×3 或 3×3。mid-P 精确检验相较 Fisher 更不保守,可在保持可靠性的同时提高统计功效。贝叶斯精确检验将先验知识纳入分析,当数据有限或专家判断可引导推断时尤其有用。此外,比值比的偏差校正方法可在小样本或出现零计数的研究中提供更准确的效应量估计,从而更好地解释关联的强度与方向。

局限与方法学注意事项

Fisher 精确检验可能偏保守,这会降低统计功效,使得在小样本研究中更难检测到真实效应。当数据非常稀疏时,结果解释也更具挑战性,因为置信区间会变宽、估计更不稳定。此外,在许多变量上反复应用该检验会增加伪发现风险,因此需谨慎进行多重检验校正。

列联表的可视化

用于呈现列联表结果的良好可视化方式包括堆叠条形图、并列条形图和马赛克图。这些图形有助于清晰传达分类变量之间的关系,并使模式或群体差异更易解读。

以下是一个 Python 示例:

import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd

df = pd.DataFrame({
    "Outcome": ["Recovered", "Not Recovered"] * 2,
    "Count": [8, 2, 4, 11],
    "Group": ["Treatment"] * 2 + ["Control"] * 2
})

sns.barplot(data=df, x="Outcome", y="Count", hue="Group")
plt.title("Treatment Outcomes")
plt.show()

结语

Fisher 精确检验仍然是分析小型分类数据集的金标准。从花园中的一杯茶开始,它已经发展为医学、遗传学、人类研究与商业实验等领域重要且广泛使用的工具。

报名参加我们的 Python 统计基础技能路径,继续学习假设检验等主题,并通过线性回归与逻辑回归练习统计建模。

Vidhi Chugh

作者

Vidhi Chugh

我是一名 AI 策略与伦理专家,致力于在数据科学、产品与工程的交汇处构建可扩展的机器学习系统。曾入选全球“200 位顶尖商业与技术创新者”之一,我的使命是让机器学习走向大众,打破术语壁垒,让每个人都能参与这场变革。

常见问题

Fisher 精确检验与卡方检验有何不同?

Fisher 精确检验计算的是精确概率,适合小样本或期望频数字段较低的情况;而卡方检验依赖大样本近似,当期望计数低于 5 时会变得不准确。

Fisher 精确检验在医学研究中的实际应用有哪些?

它常用于评估治疗效果、分析临床试验结局、研究疾病关联以及遗传风险因素——尤其是在患者样本量较小时。

Fisher 精确检验能用于大于 2×2 的列联表吗?

可以。Fisher 精确检验可通过 Freeman–Halton 扩展用于更大的列联表,但随着表规模增大,计算量也会显著增加。

使用 Fisher 精确检验需要满足哪些假设?

数据必须是分类变量,按列联表组织,观测相互独立,且在原假设下行列合计固定。

超几何分布与 Fisher 精确检验有何关系?

Fisher 精确检验的概率计算基于超几何分布,该分布刻画在固定边际总数下,观测到特定计数排列的可能性。

主题