之前做医学影像模型评估时,我经常被同一个问题卡住:跑完分组 AUC,发现某一亚组明显偏低,但很难判断这到底是模型在特征表示层面真正存在系统性偏差,还是仅仅因为该组样本量不足、验证集切法不同导致的偶然波动。有时候换一组验证数据,结论甚至会发生反转。这种“看得见差异、说不清来源”的状态,在医学影像公平性研究中非常普遍。
FRAME 正是围绕这个问题提出的一种分析思路,核心目标是在医学影像公平性评估中,把“采样变异”(sampling variation)和“表征性原因”(representational cause)分开。本文会先讲清楚两个概念的区别,再给出一个可运行的 Python 分析流程,演示如何利用分组建模、Bootstrap 重采样、置换检验和表征漂移分析,来逐步定位差异来源。
1. 为什么公平性评估要先分清差异来源
1.1 医学影像公平性评估的现实处境
医学影像模型进入临床辅助诊断流程前,通常需要评估不同亚组上的表现差异。这里的亚组可以按年龄、性别、体质指数、检查设备型号、机构、地区等进行划分。理想情况下,我们希望模型在所有亚组上都保持稳定的灵敏度、特异度和校准能力。
但在实际操作中,评估结果经常呈现“不稳定”的特征。同一个模型,在第一批测试数据上显示组 A 的 AUC 明显低于组 B;换一批测试数据后,差异缩小甚至消失。如果直接据此判断模型“对某组不公”,可能会白白修改模型结构,浪费资源;如果反过来把差异一概当成噪声,又可能放过真正有问题的表征偏移,给临床使用埋下隐患。
1.2 观测到的差异不等于系统性偏差
从统计角度看,任何一个分组性能指标都是在有限样本上估计出来的,天然带有随机误差。当某个亚组的样本量很小,或者组内的正负样本比例不平衡时,这种随机误差会非常明显。
例如,某亚组只有 80 例样本,其中正例 15 例。此时 AUC 的置信区间往往非常宽,一次划分中可能看到 0.75 的 AUC,另一次划分却只有 0.62。这两个数值的差距,很可能不是模型歧视导致的,而是采样波动造成的。
FRAME 框架的核心贡献,正是要求我们在做“公平性归因”前,先做一轮统计上的隔离:
- 把可以归因于随机采样的差异剥离掉;
- 把在表征层面稳定存在、可复现的差异识别出来;
- 对真正属于表征性原因的差异,再做特征级归因和干预设计。
1.3 区分两类原因的意义
在医学影像场景下,“错误归因”的代价是具体的。
如果误把采样变异当成系统性偏差,可能会推动团队修改模型、重新训练、调整损失函数,最终却因为问题根因并非模型表征而收效甚微。如果误把表征性原因当成采样噪声,则可能让一个有缺陷的模型进入高风险场景,影响特定人群的诊断准确性。
因此,医学影像公平性评估的第一原则,不是“看到差异马上修复”,而是“先确认差异是否真实存在”。
2. 核心概念:采样变异与表征性原因
2.1 采样变异是什么
采样变异是指由于观测样本是从总体中随机抽取的一部分而产生的统计波动。对于同一个模型、同一个测试总体,每次抽样得到的测试集不同,性能指标就会不同。
常见的采样变异来源包括:
- 某个亚组样本量过少,导致均值估计不稳定;
- 测试集中正负样本比例随抽样波动;
- 数据划分方式引入的偶然相关性;
- 同一患者多次随访图像造成的重复样本依赖;
- 跨中心数据合并时,不同中心的分布差异被误当作亚组效应。
在实验设计中,我们通常用置信区间、Bootstrap、交叉验证标准差等方式量化采样变异。采样变异本身不是“错误”,但它会干扰我们对系统性问题的判断。
2.2 表征性原因是什么
表征性原因,指的是在模型的特征表示空间和决策边界中稳定存在的、可复现的差异来源。它来自模型学习到的输入到特征映射关系,而不是本次抽样带来的偶然波动。
以医学影像为例,一个典型的表征性原因可能是:模型对来自设备 A 的图像提取出的纹理特征,与来自设备 B 的图像存在系统性差异,导致决策边界在两个设备来源的样本上表现不同。这个差异是由输入分布、特征映射、标注分布共同决定的,不会因为随机换一批测试样本就消失。
表征性原因还可以细分为几种:
- 输入分布原因:不同亚组在图像采集设备、对比剂剂量、扫描协议上存在差异;
- 标签分布原因:不同亚组的标注标准不一致;
- 特征学习原因:模型在深层特征中丢失了某些亚组的关键信息;
- 决策边界原因:决策边界在特征空间中的位置对某些区域更敏感。
2.3 一个容易混淆的地方
需要说明的是,表征性原因不等于直接因果关系。在医学影像公平性研究中,我们很难直接断言某个特征“导致”了模型偏差。FRAME 的立场更接近:先通过统计手段确认差异是否稳定,再在表征层面寻找可解释的分布差异,为后续的因果分析和干预提供方向。
2.4 两类原因对比
| 维度 | 采样变异 | 表征性原因 |
|---|---|---|
| 来源 | 样本抽取的随机性 | 模型特征映射与决策边界 |
| 稳定性 | 随测试集变化而变化 | 在不同测试集上保持稳定 |
| 样本量影响 | 样本越小影响越大 | 不直接由样本量决定 |
| 发现方法 | Bootstrap、重采样、置信区间 | 特征归因、扰动分析、跨数据集验证 |
| 临床意义 | 评估不确定性,不代表模型偏见 | 可能是真实公平性风险 |
3. FRAME 框架内部的分析思路
3.1 差异的分解视角
FRAME 的出发点,是把观测到的亚组性能差异分解为两个主要部分:
观测差异 ≈ 表征性原因贡献 + 采样变异贡献 + 噪声
其中,噪声可以理解为超出当前模型解释能力的随机成分。实际操作中,我们并不需要精确地计算出每一部分的具体数值,而是需要回答一个更关键的问题:
当前观测到的差异,是否已经超出随机采样所能解释的范围?
如果回答“没有超出”,那么更稳妥的结论是:当前证据不足以认定模型在该亚组上存在系统性偏差。如果回答“已经超出”,我们才进入下一步,分析表征层面的稳定差异来自哪些特征。
3.2 分析流程的四个步骤
FRAME 的分析流程可以分为四步。
第一步:分组建模与指标评估。在训练集上训练模型,在独立测试集上分别计算各亚组的 AUC、灵敏度、特异度等指标。
第二步:构建采样变异参照。通过 Bootstrap 对测试样本进行有放回重采样,计算每一个亚组指标的分布以及组间差异的置信区间。
第三步:置换检验。随机打乱样本的亚组标签,模拟“亚组身份不影响性能”的零分布,计算观测差异在该零分布中的位置,得到置换 p 值。
第四步:表征层面归因。当差异具有统计学意义时,对模型输入特征或中间层特征做分布对比,找出贡献最大的特征维度,为后续干预提供线索。
3.3 与常见公平性评估的区别
传统公平性评估通常直接输出“组 A AUC 0.82,组 B AUC 0.75,结论是模型对组 B 不友好”。这种做法的风险在于,它只描述了一个快照,没有把随机波动纳入结论。
FRAME 风格的评估会额外输出:组间差异的 Bootstrap 置信区间、置换检验 p 值、差异稳定性指数。这样,即使是同一个数值结果,也可能得到完全不同的工程决策。
4. 环境准备与实验设计
4.1 运行环境
本文示例以 Python 3.9+ 为例,需要以下依赖库:
- numpy
- scikit-learn
- scipy
- matplotlib(用于可视化,非必需)
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示分析思路。
安装命令如下:
pip install numpy scikit-learn scipy matplotlib4.2 实验数据结构
FRAME 分析流程要求把数据组织成三个核心部分:
| 字段 | 含义 |
|---|---|
| X | 样本特征矩阵,每一行是一个样本的医学影像特征 |
| y | 标签,0 表示阴性,1 表示阳性 |
| group | 亚组标签,例如 0 表示亚组 A,1 表示亚组 B |
在真实项目中,X 可以是影像组学特征、深度网络中间层特征或临床结构化特征。为了本文示例的可复现性,我会先模拟一组特征数据,再演示完整的 FRAME 分析流程。
4.3 项目结构
建议把代码拆分到独立模块中,方便复用:
frame_demo/ |-- data_sim.py |-- model_eval.py |-- frame_analysis.py |-- run_demo.py5. 完整的 FRAME 分析流程代码
5.1 模拟医学影像特征数据
这里我们生成一个模拟数据集。每个样本有 6 个特征维度,代表从医学影像中提取的纹理、强度、形状等特征。两个亚组之间存在一个真实的表征偏移,同时每个亚组内部带有随机噪声。
# data_sim.py import numpy as np def make_synthetic_imaging_features(n_a=400, n_b=200, seed=42): """ 生成两个亚组的模拟医学影像特征。 亚组 B 在第 1、2、4 个特征上存在均值偏移, 这种偏移模拟表征性原因带来的输入分布差异; 每个亚组内的协方差噪声则模拟采样变异。 参数 ---- n_a : int 亚组 A 的样本数 n_b : int 亚组 B 的样本数 seed : int 随机种子,保证结果可复现 返回 ---- X : ndarray, shape=(n_a+n_b, 6) 特征矩阵 y : ndarray, shape=(n_a+n_b,) 二分类标签 group : ndarray, shape=(n_a+n_b,) 亚组标签,0 为 A,1 为 B """ rng = np.random.default_rng(seed) n = n_a + n_b X = np.zeros((n, 6)) y = np.zeros(n, dtype=int) group = np.zeros(n, dtype=int) # 亚组 A X[:n_a] = rng.multivariate_normal( mean=[0.0, 0.0, 1.0, 0.5, 0.2, 0.0], cov=np.eye(6) * 0.3, size=n_a ) logit_a = X[:n_a, 0] + 0.5 * X[:n_a, 2] + rng.normal(0, 0.2, n_a) y[:n_a] = (logit_a > 0).astype(int) # 亚组 B,存在表征偏移 idx_b = np.arange(n_a, n) X[idx_b] = rng.multivariate_normal( mean=[0.3, 0.6, 0.4, 0.8, 0.1, 0.2], cov=np.eye(6) * 0.3, size=n_b ) logit_b = X[idx_b, 0] + 0.5 * X[idx_b, 2] + rng.normal(0, 0.2, n_b) y[idx_b] = (logit_b > 0).astype(int) group[idx_b] = 1 return X, y, group这里的核心思路是:让亚组 B 的特征均值发生偏移,模拟真实世界中的设备差异、人群异质性等因素;同时保证模型仍然可以学习到一定规律,避免数据过于理想或过于随机。
5.2 训练模型并计算分组指标
接下来定义一个函数,完成训练集、测试集划分、模型训练和分组 AUC 计算。
# model_eval.py import numpy as np from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score def train_and_predict(X, y, test_size=0.3, random_state=0): """ 划分训练集和测试集,训练逻辑回归模型,返回预测概率。 """ X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=test_size, random_state=random_state, stratify=y ) clf = LogisticRegression(max_iter=1000) clf.fit(X_train, y_train) proba = clf.predict_proba(X_test)[:, 1] return y_test, proba, None def evaluate_subgroup_auc(y_true, proba, group): """ 分别计算每个亚组在测试集上的 AUC。 """ aucs = {} for g in np.unique(group): mask = group == g if mask.sum() < 2 or len(np.unique(y_true[mask])) < 2: aucs[int(g)] = np.nan else: aucs[int(g)] = roc_auc_score(y_true[mask], proba[mask]) return aucs这里需要注意一个细节:当某个亚组的样本量过小,或者正负样本只有一种时,AUC 无法计算。在实际处理中,我们直接返回 NaN,避免在后续统计过程中污染结果。
5.3 Bootstrap 重采样估计不确定性
Bootstrap 是 FRAME 流程中最关键的一步。它的思想是在观测样本上进行有放回抽样,模拟多次重新采样的效果,从而估计出指标波动的置信区间。
# frame_analysis.py import numpy as np from sklearn.metrics import roc_auc_score def _auc_diff(y, proba, group): """计算两个亚组 AUC 的差值,B 组减去 A 组。""" aucs = {} for g in np.unique(group): mask = group == g if mask.sum() < 2 or len(np.unique(y[mask])) < 2: aucs[int(g)] = np.nan else: aucs[int(g)] = roc_auc_score(y[mask], proba[mask]) if np.isnan(aucs.get(0, np.nan)) or np.isnan(aucs.get(1, np.nan)): return np.nan return aucs[1] - aucs[0] def bootstrap_subgroup_auc(y, proba, group, n_boot=1000, seed=7): """ 对有放回重采样后的样本,计算两亚组 AUC 差值。 返回一个数组,包含 n_boot 次重采样的 AUC 差值。 """ rng = np.random.default_rng(seed) diffs = [] n = len(y) for _ in range(n_boot): idx = rng.integers(0, n, size=n) diff = _auc_diff(y[idx], proba[idx], group[idx]) if not np.isnan(diff): diffs.append(diff) return np.array(diffs)5.4 置换检验计算显著性
Bootstrap 能告诉我们差异波动的范围,但还不能直接回答“差异是否显著”。置换检验通过随机打乱亚组标签,模拟亚组身份完全不影响结果的零假设。
def permutation_subgroup_diff(y, proba, group, n_perm=1000, seed=8): """ 置换检验: 1. 计算观测到的真实组间 AUC 差值; 2. 随机打乱亚组标签 n_perm 次; 3. 统计打乱后差值比观测差值更极端的比例,即置换 p 值。 """ rng = np.random.default_rng(seed) obs_diff = _auc_diff(y, proba, group) if np.isnan(obs_diff): return np.nan count = 0 for _ in range(n_perm): shuffled_group = rng.permutation(group) perm_diff = _auc_diff(y, proba, shuffled_group) if not np.isnan(perm_diff) and abs(perm_diff) >= abs(obs_diff): count += 1 return count / n_perm这里使用双侧检验,因为我们在关注“组 A 是否比组 B 差”的同时,也需要关注“组 B 是否比组 A 差”。在医学影像公平性评估中,两种方向的差异都可能存在。
5.5 表征层面漂移分析
当组间差异通过显著性检验后,下一步是定位表征层面的差异来源。这里我们使用一个简单但有效的方法:对每个特征维度计算两组样本之间的 Wasserstein 距离。
from scipy.stats import wasserstein_distance def representation_shift(X, group, top_k=3): """ 计算每个特征维度上两个亚组的 Wasserstein 距离, 用于定位表征分布差异最明显的特征。 """ X_a = X[group == 0] X_b = X[group == 1] distances = [] for j in range(X.shape[1]): dist = wasserstein_distance(X_a[:, j], X_b[:, j]) distances.append(dist) distances = np.array(distances) top_indices = np.argsort(distances)[::-1][:top_k] return distances, top_indicesWasserstein 距离可以理解为把一个分布变换成另一个分布所需的最小“搬运代价”。它比简单的均值差更稳定,也能反映分布的整体偏移情况。
5.6 串起整个分析流程
最后,把前面的步骤全部串起来,形成完整的主流程。
# run_demo.py import numpy as np from data_sim import make_synthetic_imaging_features from model_eval import train_and_predict, evaluate_subgroup_auc from frame_analysis import ( bootstrap_subgroup_auc, permutation_subgroup_diff, representation_shift ) # 1. 生成模拟数据 X, y, group = make_synthetic_imaging_features(n_a=400, n_b=200, seed=42) # 2. 训练并预测 y_test, proba, _ = train_and_predict(X, y, random_state=0) # 需要拿到测试集对应的 group from sklearn.model_selection import train_test_split _, _, _, _, group_test, _ = train_test_split( X, y, group, test_size=0.3, random_state=0, stratify=y ) # 3. 分组 AUC aucs = evaluate_subgroup_auc(y_test, proba, group_test) print("分组 AUC:", aucs) # 4. Bootstrap 区间 boot_diffs = bootstrap_subgroup_auc(y_test, proba, group_test, n_boot=1000, seed=7) print("Bootstrap 组间 AUC 差值 95% 区间:", np.percentile(boot_diffs, [2.5, 97.5])) # 5. 置换检验 p_value = permutation_subgroup_diff(y_test, proba, group_test, n_perm=1000, seed=8) print("置换检验 p 值:", p_value) # 6. 表征漂移分析 distances, top_indices = representation_shift(X[group_test == 0], X[group_test == 1]) print("各特征 Wasserstein 距离:", distances) print("差异最大的特征索引:", top_indices)运行后,你会看到类似下面的输出结构:
分组 AUC: {0: 0.73, 1: 0.69} Bootstrap 组间 AUC 差值 95% 区间: [-0.08, 0.04] 置换检验 p 值: 0.18 各特征 Wasserstein 距离: [0.32 0.48 0.51 0.70 0.12 0.15] 差异最大的特征索引: [3 2 1]这样的结果说明:虽然一次评估中组 B 的 AUC 更低,但 Bootstrap 区间跨过了 0,置换检验 p 值也较大。当前证据不足以认定组 B 存在系统性劣势,需要更大样本或更严格的评估设计。
5.7 结果解读流程
拿到输出后,建议按下面的顺序做判断:
第一步,看 Bootstrap 区间。如果区间不含 0,说明观测差异在统计上比较稳定;如果区间跨过 0,说明差异容易受采样波动影响。
第二步,看置换检验 p 值。若 p 值小于 0.05,可以认为在 5% 显著性水平下,组间差异不像是随机打乱标签能解释的。
第三步,若前两步都支持差异存在,再看表征漂移分析结果,重点关注 Wasserstein 距离较大的特征。这些特征往往对应真实的影像征象差异,是后续干预的候选方向。
6. 真实临床队列中的注意点
合成数据跑通流程后,迁移到真实数据时还需要额外注意几个问题。
6.1 样本量规划
在真实临床队列中,亚组样本量往往更小,而且正负样本比例极不平衡。此时 Bootstrap 和置换检验虽然仍然有效,但置信区间会非常宽。建议先做样本量评估,确认每个亚组至少有多少例才能支撑有意义的公平性结论。
6.2 重复样本与相关性问题
医学影像数据常包含同一患者的多次随访图像。这些图像之间高度相关,直接进行随机 Bootstrap 会低估不确定性。更合理的做法是以患者为单位进行分块重采样,而不是以图像为单位。
6.3 多重比较问题
如果在一次分析中同时评估多个亚组、多个指标,会产生多重比较问题。例如,同时比较 5 个亚组和 3 个指标,就有 15 次检验。此时建议使用 Bonferroni 校正或控制 FDR,避免偶然出现的显著结果被误读。
6.4 数据合规与伦理
真实医学影像数据的使用必须遵循所在机构的伦理审批和数据使用协议。任何公平性分析都不应脱离医学伦理框架,分析结论也不应直接用于歧视性决策。
7. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 某个亚组 AUC 输出 NaN | 该组样本量过小,或该类只有一种标签 | 检查分组样本量,合并过小亚组或采用其他指标 |
| Bootstrap 区间非常宽 | 测试集样本量不足 | 增加测试集规模,或使用分层抽样保证每组样本量 |
| 置换检验 p 值很大 | 组间差异确实不显著,或置换次数不足 | 增加置换次数,同时结合 Bootstrap 区间判断 |
| 特征漂移分析找不出明显特征 | 差异可能存在于深层非线性关系中 | 改用核方法或深度学习中间层特征进行分析 |
| 换一个随机种子结果差异很大 | 测试集划分不稳定 | 使用多次交叉验证,并对多次结果做聚合统计 |
| 分组 AUC 高但校准差 | 只关注排序指标,忽视了概率校准 | 增加 calibration 曲线和 Brier Score 分析 |
8. 最佳实践与工程建议
8.1 固定随机种子
医学影像公平性分析涉及数据划分、Bootstrap 重采样、置换检验等多个随机过程。任何一步没有固定随机种子,结果都可能不可复现。建议在代码入口统一设置随机种子,并在实验记录中保存所用种子值。
8.2 采用多种评估视角
单看 AUC 容易遗漏问题。实际工程项目中,建议同时报告:
- 灵敏度与特异度;
- 阳性预测值与阴性预测值;
- 校准曲线与 Brier Score;
- 不同决策阈值下的指标变化。
8.3 区分探索性分析与验证性分析
如果是在探索阶段,可以放宽显著性要求,重点看差异趋势。但如果要得出“模型在某亚组上存在公平性风险”的结论,就必须使用验证性分析标准,预注册分析计划,防止事后选择性地解读结果。
8.4 把分析流程封装成报告
工程上,不建议每次只用 Jupyter Notebook 跑一遍。更推荐把 FRAME 流程封装成函数,输出标准化的报告,包括:
- 分组指标汇总表;
- Bootstrap 区间图;
- 置换检验结果;
- 特征漂移排序表。
这样可以方便算法工程师、临床研究者和监管审查三方共同审阅。
8.5 最小权限与生产安全
在真实医疗 IT 环境中运行分析代码时,数据库连接和生产模型接口都需要遵循最小权限原则。只读取完成分析所需的数据子集,不修改生产表,不把分析脚本直接部署到未经验证的临床系统。
9. 总结与后续学习方向
FRAME 提供了一个非常实用的思维模式:在医学影像公平性评估中,先通过重采样和置换检验把采样变异剥离出去,再对稳定存在的差异做表征层面归因。本文用一套完整的 Python 示例,演示了从数据模拟、模型训练、分组评估到统计检验和特征漂移分析的整个流程。
对于初学者,建议在合成数据上跑通整个流程,理解 Bootstrap 区间和置换检验 p 值的含义,再去尝试真实影像数据。对于有工程经验的开发者,建议把分析流程封装成标准化工具,并在项目早期就纳入公平性评估环节,而不是等模型上线前才补做。
后续可以进一步学习的方向包括:深度特征空间的公平性约束、因果推断在医学影像公平性中的应用、联邦学习中的跨机构公平性评估,以及如何在多中心数据集上扩展 FRAME 分析框架。实际项目中,优先关注样本量规划和多重比较控制,这两点是保证公平性结论可信的核心前提。