资讯动态

FRAME框架:医学影像公平性归因分析中的采样变异与表征原因

发布时间:2026/8/31 8:42:12 来源:尧图企业网站定制
先说明一下这里的 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_major200, n_patients_minor80, images_per_patient4, n_features16, random_state42, ): 构造模拟数据集。 这里用 16 维特征模拟影像模型提取的特征向量。 majority 组特征均值接近 0minority 组特征均值偏移到 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, sizen_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, sizen_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_size0.3, random_state42): 按患者做训练/测试划分避免同一个患者的多张影像同时出现在两边。 unique_patients np.unique(patients) train_patients, test_patients train_test_split( unique_patients, test_sizetest_size, random_staterandom_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_bootstrap1000, random_state42, ): 患者级自助法每次重采样都以患者为单位。 返回每个 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, sizen_patients, replaceTrue) 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_interestminor, random_state42): 训练一个轻量分类器判断特征来自哪个群体。 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_size0.3, random_staterandom_state, stratifyy_domain, ) clf make_pipeline( StandardScaler(), LogisticRegression(max_iter1000), ) 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, k5, threshold_percentile90): 计算 minority 样本落在 majority 特征分布内的比例。 对每个 minority 样本找到它在 majority 样本中的 k 个最近邻 得到平均距离。再用 majority 样本自身的近邻距离分布作为参照 低于阈值的 minority 样本视为“覆盖良好”。 if len(X_minor) 0 or len(X_major) 0: return float(nan) nn NearestNeighbors(n_neighborsmin(k, len(X_major))) nn.fit(X_major) dists, _ nn.kneighbors(X_minor) mean_dists dists.mean(axis1) 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_size0.3, random_state42) ) model make_pipeline( StandardScaler(), LogisticRegression(max_iter1000), ) 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(indexFalse)) print() print( 第二层患者级 Bootstrap 置信区间 ) dist bootstrap_auc_by_group( y_test, y_score, groups_test, patients_test, n_bootstrap1000, random_state42, ) 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(fminor - 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_interestminor) 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], k5) print(fminority 特征覆盖率: {coverage:.3f}) if __name__ __main__: main()运行方式cd medical_fairness python run_analysis.py输出会包括三个层次的内容各组指标、各组 AUC 的置信区间、组间差异置信区间以及两个表征归因指标。具体数值会因为你修改随机种子或样本量而变化重点看结论方向。4.6 预期输出格式示例输出格式类似下面这样

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价