先说明一下,这里的 FRAME 不是视频处理里的逐帧抽帧工具,而是医学影像公平性归因分析中的一种思路框架。它的核心命题很直接:当我们发现模型在某个亚组上的表现明显更差时,这个“更差”到底是采样变异带来的统计噪声,还是模型表征层面真正缺失导致的系统性问题?如果区分不了,后续的数据补充、模型迭代、临床部署都可能走错方向。
这篇文章会围绕 FRAME 的核心思想,把“采样变异”和“表征性原因”拆开讲清楚,然后给出一套用 Python 可落地的分析流程。内容包括公平性指标计算、患者级重采样置信区间估计、特征分布偏移检测、表征覆盖率分析,以及结果如何用于模型发布决策。适合正在做医学影像 AI 评估、模型上线前公平性自查、或者想理解公平性根因分析的算法工程师阅读。
1. 医学影像公平性为什么难评估
1.1 模型上线前必须面对的问题
医学影像 AI 已经覆盖了胸部 X 光、眼底照片、病理切片、CT 影像等大量场景。这类模型的临床价值很高,但部署前有一个绕不开的问题:模型在整体指标上可能很好看,比如 AUC 达到 0.95,但到了某个特定人群上,性能可能掉到 0.80,甚至更低。
这种跨群体性能差异就是医学影像公平性要解决的核心问题。公平性问题不只是“伦理正确”,它直接关系到患者的诊断质量。如果一个模型对女性、老年人、某个疾病亚型或某种影像采集设备的样本系统性地诊断不准,那么它进入临床后,只会复制并放大训练数据里的偏差。
所以说,在做医学影像 AI 时,不能只看平均指标,还必须按敏感属性、疾病亚型、影像采集条件等维度做分层评估。
1.2 直接看指标差距会误判问题
很多团队的评估流程是:按性别或年龄段分组,分别计算 AUC,然后把各组 AUC 放在一起比较,谁低就认为模型对哪组不公平。这个流程看起来简单,但存在一个隐患:小样本组的 AUC 是不稳定的。
AUC 本身是一个基于排序计算的统计量。当一组样本只有几十例时,AUC 的置信区间可能非常宽。你看到的“0.70 vs 0.85”可能只是随机波动,而不是模型真的对前一组不公平。反过来,如果一组样本量大,AUC 差距即使只有 0.03,也可能在统计上显著,但临床意义未必很大。
FRAME 要解决的就是这个“归因混淆”问题。它主张在判断模型是否存在不公平之前,先把性能差异拆解为两类来源:
- 采样变异:由于某组样本量有限,导致指标随机波动;
- 表征性原因:模型对某组人群在影像特征空间中的表征不够完整,导致系统性诊断偏差。
只有确认差异不能被采样变异解释时,才应该进入“模型公平性修复”的环节。
1.3 为什么叫“表征性原因”
“表征性原因”听起来有点抽象,用通俗的话说就是:模型没有学会充分表达某个群体的影像特征。
举个例子。假设训练数据里多数患者的病灶出现在肺中叶,而某个人群的病灶更多分布在上叶边缘,且影像纹理差异较大。如果模型在表征学习阶段没有见过足够多样的上叶边缘病灶,它在特征空间中就无法对该人群形成有效的特征表达,最终表现为对这个人群的误诊率偏高。
这种问题不是简单的“样本少加几条数据”就能解决的。你需要检查特征空间分布,分析模型是否覆盖了该人群的真实影像形态。FRAME 的价值就是提供一套流程,把这些判断从“拍脑袋”变成可量化分析。
2. FRAME 核心概念拆解
2.1 采样变异是什么
采样变异(sampling variation)描述的是:由于样本数量和抽样随机性,同一个模型的同一个指标在不同数据子集上会波动。
在医学影像场景中,这种波动非常常见。比如一个小型测试集里阳性样本只有 20 例,多一例被正确分类,AUC 可能提升 0.05;多一例被错误分类,AUC 可能下降 0.08。这里的差异不能代表模型真实能力的差异。
采样变异的关键特征:
- 与模型本身无关;
- 与该群体的真实影像特征无关;
- 只与测试集的构成和样本量有关;
- 增大测试集样本量后,波动会明显减小。
换句话说,采样变异是一种“统计噪声”。如果两个群体之间的性能差异落在噪声范围内,就不能认定模型存在公平性问题。
2.2 表征性原因是什么
表征性原因(representational cause)是比采样变异更深层的机制。它指的是模型在特征空间中没有对某一群体形成足够的表征覆盖,导致模型对该群体的预测不稳定或系统性偏差。
这种问题常见于以下情况:
- 某个群体的典型病灶形态在训练集中出现很少;
- 某一类影像特征只与特定人群相关,但模型没有学习到该关联;
- 不同人群的影像存在可区分的分布偏移,模型只学会了主要群体的特征模式;
- 数据采集设备、图像预处理方式在不同群体之间存在差异。
表征性原因有两个重要特点:
- 它不会因为随机抽样的改变而消失;
- 即使增加随机样本,只要新样本仍然来自同一分布缺口,问题仍会存在。
所以,FRAME 认为,只有排除采样变异影响后,仍然存在显著的差异,才有必要去做数据采集和模型结构的调整。
2.3 两类原因的区别
可以用一个简单表格来对比:
| 维度 | 采样变异 | 表征性原因 |
|---|---|---|
| 本质来源 | 统计噪声、样本量不足 | 特征空间覆盖不足、表征学习缺陷 |
| 是否与模型相关 | 低相关 | 强相关 |
| 增加样本是否能解决 | 通常可以 | 不一定,要看新增样本是否补足分布缺口 |
| 在重复实验中的表现 | 指标波动明显 | 指标差距稳定存在 |
| 修复重点 | 数据量、采样策略 | 数据分布、特征表示、训练策略 |
实际项目中,两者往往会同时出现。FRAME 的意义不是把问题简单归为某一类,而是先分离出采样变异的“底噪”,再去看剩余差异是否有足够的表征证据支持。
3. FRAME 分析框架的整体思路
FRAME 的工程化落地可以拆成三步:分层评估、重采样解离、表征归因。下面分别说明每一步要做什么,以及为什么这样做。
3.1 第一步:先按敏感维度分层评估
不要一上来就训练复杂模型。你首先需要一个“对照组”式的评估结果。
具体做法:
- 确定要评估的敏感维度,例如性别、年龄段、疾病亚型、扫描设备;
- 在测试集上按维度分组;
- 分别计算每个分组的 AUC、balanced accuracy、sensitivity、specificity;
- 记录每个分组的样本量。
这一步看似基础,但很有价值。很多团队在这一步就会发现问题:某个分组的样本量只有几十例,其他组有几千例。此时你应该意识到,后续所有比较都必须考虑样本量。
3.2 第二步:用重采样分离采样变异
当发现分组指标存在差距时,不要直接下结论。你要用量化方法回答一个问题:这个差距在多大程度上可以被随机抽样解释?
推荐使用自助法(bootstrap)进行患者级重采样。基本思想是:
- 从当前分组的患者集合中有放回地抽取同样数量的患者;
- 计算该组的 AUC;
- 重复 1000 次以上;
- 得到 AUC 的经验分布和置信区间;
- 同时计算两组 AUC 差值的置信区间。
如果差值的 95% 置信区间包含 0,说明现有数据下无法排除采样变异的影响。此时给“模型不公平”下结论是不严谨的。
需要强调的是,医学影像中同一个患者往往有多张影像,比如多个切面、多张视野图。如果以影像为单位做 bootstrap,同一患者的重复样本会被当成独立样本,这会低估采样变异,导致置信区间过窄。因此,bootstrap 的抽样单位必须是患者,不是影像。
3.3 第三步:对显著差异做表征归因
如果组间差异在统计上仍然显著,就进入表征归因阶段。这个阶段的目标是寻找“为什么模型对某组表现差”的证据。
常用的量化指标有三个:
- 域分类器 AUC:训练一个分类器,判断影像特征来自哪个群体。AUC 越高,说明两组特征分布越容易区分,模型很可能只在学习主流群体的特征;
- 特征空间覆盖率:计算少数群体样本在多数群体特征空间邻域内的覆盖率。覆盖率低,说明少数群体样本落在特征分布边缘;
- 失败样本聚类:把分类错误的样本聚类,观察它们是否集中在某些影像模式上。
这三个指标从不同角度回答表征性原因是否存在。FRAME 的价值在于,它不只给你一个结论,而是给你一组可解释、可追踪的证据。
4. 完整实战:用 Python 实现 FRAME 分析流程
下面用 Python 实现完整流程。为了方便运行,我构造了一份模拟医学影像特征数据。实际项目中,你可以把这里的 X、y、groups、patients 替换为真实影像模型的特征输出和标签。
4.1 模拟数据和项目结构
建议的项目结构如下:
medical_fairness/ ├── run_analysis.py └── outputs/run_analysis.py中包含数据生成、模型训练、分组评估、重采样和表征归因全部代码。以下代码可以直接复制保存。
# run_analysis.py """ FRAME 思路的医学影像公平性归因分析示例 重点:区分采样变异与表征性原因 """ import numpy as np import pandas as pd from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, balanced_accuracy_score from sklearn.model_selection import train_test_split from sklearn.neighbors import NearestNeighbors from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler def make_simulation_data( n_patients_major=200, n_patients_minor=80, images_per_patient=4, n_features=16, random_state=42, ): """ 构造模拟数据集。 这里用 16 维特征模拟影像模型提取的特征向量。 majority 组特征均值接近 0,minority 组特征均值偏移到 0.6, 用于模拟两个群体在影像表征上的真实分布差异。 """ rng = np.random.default_rng(random_state) X_list, y_list, group_list, patient_list = [], [], [], [] for group_name, n_patients, shift in [ ("major", n_patients_major, 0.0), ("minor", n_patients_minor, 0.6), ]: for p in range(n_patients): base_feature = rng.normal(shift, 1.0, size=n_features) label = rng.binomial(1, 0.4) pid = f"{group_name}_{p:04d}" for _ in range(images_per_patient): X_list.append(base_feature + rng.normal(0, 0.25, size=n_features)) y_list.append(label) group_list.append(group_name) patient_list.append(pid) X = np.array(X_list) y = np.array(y_list) groups = np.array(group_list) patients = np.array(patient_list) return X, y, groups, patients def patient_level_split(X, y, groups, patients, test_size=0.3, random_state=42): """ 按患者做训练/测试划分,避免同一个患者的多张影像同时出现在两边。 """ unique_patients = np.unique(patients) train_patients, test_patients = train_test_split( unique_patients, test_size=test_size, random_state=random_state, ) train_mask = np.isin(patients, train_patients) test_mask = np.isin(patients, test_patients) return ( X[train_mask], X[test_mask], y[train_mask], y[test_mask], groups[train_mask], groups[test_mask], patients[train_mask], patients[test_mask], )代码说明:
make_simulation_data构造两个群体:major 和 minor。minor 群体特征均值偏移,模拟真实影像表征差异;- 每个患者生成 4 张“影像”,后续分析以患者为单位,防止假独立样本;
patient_level_split保证同一个患者的所有影像只会进入训练集或测试集。
4.2 封装分组指标计算函数
接下来封装分组指标计算。除了 AUC,还加入 balanced accuracy,因为医学影像经常面对类别不平衡,balanced accuracy 比普通 accuracy 更可靠。
def group_metrics(y_true, y_score, groups, patients): """ 按群体分组计算常用公平性评估指标。 """ rows = [] for g in np.unique(groups): mask = groups == g yt = y_true[mask] ys = y_score[mask] if len(np.unique(yt)) < 2: continue n_patients = len(np.unique(patients[mask])) n_images = int(mask.sum()) rows.append({ "group": g, "n_patients": n_patients, "n_images": n_images, "auc": roc_auc_score(yt, ys), "balanced_acc": balanced_accuracy_score(yt, ys > 0.5), }) return pd.DataFrame(rows)这里要注意:如果某一组只有单一类别样本,AUC 无法计算。代码中用len(np.unique(yt)) < 2做了跳过处理。真实项目遇到这种情况,需要先补充样本或改用其他指标。
4.3 患者级自助法置信区间
这是 FRAME 分离采样变异的关键步骤。bootstrap 的抽样单位是患者,不是影像。
def bootstrap_auc_by_group( y_true, y_score, groups, patients, n_bootstrap=1000, random_state=42, ): """ 患者级自助法:每次重采样都以患者为单位。 返回每个 group 的 AUC 经验分布。 """ rng = np.random.default_rng(random_state) unique_patients = np.unique(patients) n_patients = len(unique_patients) patient_to_idx = {pid: np.where(patients == pid)[0] for pid in unique_patients} group_names = np.unique(groups) dist = {g: [] for g in group_names} for _ in range(n_bootstrap): boot_patients = rng.choice(unique_patients, size=n_patients, replace=True) idx = np.concatenate([patient_to_idx[pid] for pid in boot_patients]) for g in group_names: mask = groups[idx] == g yt = y_true[idx][mask] ys = y_score[idx][mask] if len(np.unique(yt)) < 2: continue try: dist[g].append(roc_auc_score(yt, ys)) except ValueError: continue return {g: np.array(v) for g, v in dist.items()}这段代码的思路:
- 每次从全量测试患者中有放回抽出同样数量的患者;
- 抽取对应患者的影像数据;
- 分别计算每个组的 AUC;
- 重复 1000 次,得到每个组的 AUC 分布。
通过这个分布,可以计算每个组 AUC 的 95% 置信区间,也能计算组间 AUC 差值是否包含 0。
4.4 表征差异分析函数
如果说 bootstrap 是“统计显微镜”,那么下面的域分类器 AUC 和特征覆盖率就是“表征探针”。
以下函数实现两组特征的行为抽象分析:
def covariate_shift_score(X, groups, group_of_interest="minor", random_state=42): """ 训练一个轻量分类器,判断特征来自哪个群体。 AUC 越接近 1,说明两组特征分布越容易区分。 """ y_domain = (groups == group_of_interest).astype(int) X_train, X_test, y_train, y_test = train_test_split( X, y_domain, test_size=0.3, random_state=random_state, stratify=y_domain, ) clf = make_pipeline( StandardScaler(), LogisticRegression(max_iter=1000), ) clf.fit(X_train, y_train) proba = clf.predict_proba(X_test)[:, 1] return roc_auc_score(y_test, proba) def representation_coverage(X_major, X_minor, k=5, threshold_percentile=90): """ 计算 minority 样本落在 majority 特征分布内的比例。 对每个 minority 样本,找到它在 majority 样本中的 k 个最近邻, 得到平均距离。再用 majority 样本自身的近邻距离分布作为参照, 低于阈值的 minority 样本视为“覆盖良好”。 """ if len(X_minor) == 0 or len(X_major) == 0: return float("nan") nn = NearestNeighbors(n_neighbors=min(k, len(X_major))) nn.fit(X_major) dists, _ = nn.kneighbors(X_minor) mean_dists = dists.mean(axis=1) threshold = np.percentile(mean_dists, threshold_percentile) return float((mean_dists <= threshold).mean())covariate_shift_score解决一个关键问题:两组影像特征分布是否已经显著分离开来?如果分类器很容易判断一个特征来自哪个群组,说明模型很可能只是在“记忆主流群体的模式”。
representation_coverage则进一步量化“少数群体的特征是否落在多数群体特征空间内”。覆盖率越低,说明少数群体的特征约偏离主流表征分布。
4.5 主流程脚本
最后把上述函数串起来,在main中完成整个 FRAME 分析流程:
def main(): X, y, groups, patients = make_simulation_data() X_train, X_test, y_train, y_test, groups_train, groups_test, patients_train, patients_test = ( patient_level_split(X, y, groups, patients, test_size=0.3, random_state=42) ) model = make_pipeline( StandardScaler(), LogisticRegression(max_iter=1000), ) model.fit(X_train, y_train) y_score = model.predict_proba(X_test)[:, 1] print("========== 第一层:分层公平性指标 ==========") print(group_metrics(y_test, y_score, groups_test, patients_test).to_string(index=False)) print() print("========== 第二层:患者级 Bootstrap 置信区间 ==========") dist = bootstrap_auc_by_group( y_test, y_score, groups_test, patients_test, n_bootstrap=1000, random_state=42, ) for g, values in dist.items(): if len(values) == 0: continue ci = np.quantile(values, [0.025, 0.5, 0.975]) print(f"{g}: AUC median={ci[1]:.3f}, 95% CI=[{ci[0]:.3f}, {ci[2]:.3f}]") if "major" in dist and "minor" in dist: major_auc = np.asarray(dist["major"]) minor_auc = np.asarray(dist["minor"]) n_compare = min(len(major_auc), len(minor_auc)) diff = minor_auc[:n_compare] - major_auc[:n_compare] ci = np.quantile(diff, [0.025, 0.5, 0.975]) print(f"minor - major AUC差: median={ci[1]:.3f}, 95% CI=[{ci[0]:.3f}, {ci[2]:.3f}]") print() print("========== 第三层:表征归因证据 ==========") shift_auc = covariate_shift_score(X_test, groups_test, group_of_interest="minor") print(f"域分类器 AUC: {shift_auc:.3f}") major_mask = groups_test == "major" minor_mask = groups_test == "minor" coverage = representation_coverage(X_test[major_mask], X_test[minor_mask], k=5) print(f"minority 特征覆盖率: {coverage:.3f}") if __name__ == "__main__": main()运行方式:
cd medical_fairness python run_analysis.py输出会包括三个层次的内容:各组指标、各组 AUC 的置信区间、组间差异置信区间,以及两个表征归因指标。具体数值会因为你修改随机种子或样本量而变化,重点看结论方向。
4.6 预期输出格式示例
输出格式类似下面这样:
========