在机器学习模型的公平性评估中,Disparate Impact(差异化影响,简称 DI)是出场率最高的指标之一。它通常被定义为一个受保护组群的预测通过率与参考组群预测通过率之比。很多团队习惯用“DI 必须大于 0.8”这类硬性阈值来判断模型是否达标。但在实际项目里,这种单点阈值很容易让几乎所有模型评审都变成“默认不通过”。原因不是 DI 没有意义,而是小样本、标签噪声、决策阈值变化以及分组定义不清晰都会让这个比值失真。下面从 DI 的定义开始,再到可复用的评估流程,说明为什么不能只靠一个比值下结论,以及如何用工程方法把误报降下来。
1. 先理解 Disparate Impact 是什么
1.1 从“不同分组的通过率”讲起
DI 的计算不依赖模型内部结构,只依赖模型输出的最终决策。假设一个信用评分模型对用户输出“通过”或“拒绝”,分组字段是地区、年龄区间或系统内部定义的用户群体。DI 比较的是某一组用户的“通过率”与另一组用户的“通过率”之间的比例。
公式可以写成:
DI = 受保护组的通过率 / 参考组的通过率如果两组用户的通过率完全一致,DI 等于 1.0。如果受保护组通过率不到参考组的八成,也就是 DI 小于 0.8,很多评审流程会直接判定为“存在明显差异”。这就是常被提到的“80% 规则”的简化版本。
用一段 Python 代码可以快速算出这个值:
import pandas as pd import numpy as np def selection_rate(df: pd.DataFrame, group_col: str, positive_col: str, group: str) -> float: sub = df[df[group_col] == group] if len(sub) == 0: return np.nan return sub[positive_col].mean() def disparate_impact( df: pd.DataFrame, group_col: str, positive_col: str, protected: str, reference: str ) -> float: protected_rate = selection_rate(df, group_col, positive_col, protected) reference_rate = selection_rate(df, group_col, positive_col, reference) if reference_rate == 0: return np.nan return protected_rate / reference_rate这里有一个需要提前确认的问题:受保护组和参考组分别指什么。不同项目里的定义可能完全不同。有的场景把人数较少的一方看成受保护组,有的场景把业务统计中处于弱势的一方看成受保护组。计算 DI 之前,必须把分组定义、正样本定义和参考组定义写清楚,否则同一个模型会算出完全不同的指标。
1.2 为什么只看一个比率会失真
DI 本质上是两个比例的比值,它只能反映“分组与预测结果之间是否存在强的相关性”,不能直接说明“模型是否在歧视某个群体”。
一个典型的失真场景是:受保护组本身的正样本真实基率就低于参考组。比如注册用户中,A 组用户历史逾期率确实比 B 组高,模型根据历史风险特征输出较低的通过率,这会导致 DI 小于 0.8。但模型可能只是把历史规律学了出来,未必是故意设置了一个不公平的分组规则。
反过来,有些模型预测概率分布接近,但分数越高集中在某一组。此时不同决策阈值下的 DI 会剧烈变化。阈值从 0.3 调整到 0.7,DI 可能从合格变成不合格。于是,DI 好与坏,有时候不是模型本身的稳定属性,而是评审者选择的“决策边界”在起作用。
所以在引入任何严格阈值之前,要先明确:DI 是一个用来发现差异的警示信号,不是最终裁决。它适合放在评估报告的前半段,用来提示团队“这里值得继续往下查”。
2. 用最小数据集跑通指标计算
2.1 构造一份带预测概率和分组的样例数据
为了看清楚 DI 的计算过程,先造一组最简单的数据。这里有两组用户 A 和 B,模型输出预测概率prob,默认决策阈值为 0.5。
np.random.seed(42) n_a = 1000 n_b = 1000 df = pd.DataFrame({ 'group': ['A'] * n_a + ['B'] * n_b, 'prob': np.concatenate([ np.random.normal(0.55, 0.15, n_a), np.random.normal(0.45, 0.15, n_b) ]) }) df['positive'] = (df['prob'] >= 0.5).astype(int)这份数据中,A 组和 B 组用户数量相同,但 A 组预测概率均值偏高。这样设置是为了让 DI 在阈值 0.5 时小于 1,方便观察计算结果。
2.2 计算选择率和 DI
调用前面定义的函数,得到两组通过率以及 DI:
rate_a = selection_rate(df, 'group', 'positive', 'A') rate_b = selection_rate(df, 'group', 'positive', 'B') di = disparate_impact(df, 'group', 'positive', 'A', 'B') print(f"A 组通过率: {rate_a:.4f}") print(f"B 组通过率: {rate_b:.4f}") print(f"DI: {di:.4f}")正常输出可能接近:
A 组通过率: 0.6320 B 组通过率: 0.3680 DI: 1.7174如果拿 A 组当受保护组、B 组当参考组,得到的是反向比值。实际业务中一般会固定方向,比如只计算“关注组 / 参考组”或者“少数群体 / 多数群体”。建议在计算函数中明确传参,避免不同同学跑出相反结果。
2.3 用 Bootstrap 看 DI 的置信区间
单点 DI 无法反映随机波动。更稳的做法是用自助抽样(Bootstrap)重复计算多次,得到 DI 的置信区间。
def bootstrap_di( df: pd.DataFrame, group_col: str, positive_col: str, protected: str, reference: str, n_iter: int = 2000, seed: int = 42 ) -> dict: rng = np.random.default_rng(seed) values = [] for _ in range(n_iter): sample = df.sample(frac=1.0, replace=True, random_state=rng) val = disparate_impact(sample, group_col, positive_col, protected, reference) if not np.isnan(val): values.append(val) return { 'lower': np.quantile(values, 0.025), 'median': np.quantile(values, 0.5), 'upper': np.quantile(values, 0.975) } result = bootstrap_di(df, 'group', 'positive', 'A', 'B') print(result)输出示例:
{'lower': 1.5401, 'median': 1.7183, 'upper': 1.9195}如果置信区间横跨 0.8 或 1.0,说明当前样本量不足以支撑“差异是否真实存在”的结论。此时直接给模型打上“不通过”的标签,依据并不充分。
3. 指标误报的工程原因
3.1 小样本会让 DI 像随机数
这是评审流程里最容易被忽视的问题。假设某个受保护组一共只有 40 个样本,其中 4 个被预测为正类,通过率是 10%。参考组有 10000 个样本,通过率是 20%。计算出来的 DI 是 0.5,看起来非常“严重”。
但只看数据量就知道,40 个样本里的 4 个正类,只要其中一个样本翻转,通过率就会从 10% 变成 12.5%,DI 也随之变化。更极端时,如果受保护组里只有 1 个正类样本,DI 的稳定性几乎为零。
下面的表格展示了不同样本量下同样“10% 对 20%”的比例可能带来的判断差异:
| 受保护组样本量 | 正类样本数 | 受保护组通过率 | 参考组通过率 | DI | 判断建议 |
|---|---|---|---|---|---|
| 40 | 4 | 10% | 20% | 0.50 | 样本不足,建议继续观察 |
| 200 | 20 | 10% | 20% | 0.50 | 需要结合置信区间判断 |
| 2000 | 200 | 10% | 20% | 0.50 | 差异方向值得深入排查 |
生产环境中,如果模型评估样本不够,先不要讨论阈值,而是先补数据、延长观察周期,或者使用分层抽样保证每个分组都能获得足够样本。
3.2 决策阈值移动会影响 DI 结论
很多模型输出的是预测概率,而不是最终决策。业务方在 0.5 处切一刀,DI 是 0.75;把阈值改成 0.4,DI 可能变成 0.82;改成 0.6,DI 又可能掉到 0.7。
用同一份数据扫描不同阈值,可以很清楚看到这个现象:
for threshold in [0.3, 0.4, 0.5, 0.6, 0.7]: df['decision'] = (df['prob'] >= threshold).astype(int) di_value = disparate_impact(df, 'group', 'decision', 'A', 'B') print(f"threshold={threshold:.1f}, DI={di_value:.4f}")输出类似:
threshold=0.3, DI=1.4950 threshold=0.4, DI=1.6032 threshold=0.5, DI=1.7174 threshold=0.6, DI=1.8703 threshold=0.7, DI=2.0562这个例子中 DI 随阈值升高而增大,说明 A 组的高分样本相对更集中。换个业务场景,也可能出现 DI 随阈值升高而下降的情况。因此,DI 报告必须同时写明“使用的是哪个阈值”,否则线上决策阈值一旦调整,之前的评估结论就失效了。
3.3 标签噪声和基率差异会污染对比基准
真实业务里的标签并不是准确无误的。用户投诉、人工审核、标注规则变化都会引入噪声。如果 A 组的标签噪声远高于 B 组,模型学到的“规律”就会失真,计算出的选择率也会失真。
另外,两个组的正样本基率天然不同时,不能直接期望模型输出完全相同的通过率。举例来说,一个风险模型面对的两个用户群体,一个群体平均收入更高、违约率低,另一个群体平均收入偏低、违约率高。只要模型接入的收入、负债、历史行为特征存在差异,预测结果就不可能完全一样。此时 DI 偏低是“风险分布不同”的结果,不能简单归因于模型有偏见。
正确的做法是先把数据按照业务关键变量做分层,比如收入区间、城市等级、产品类型。分层之后再计算各组 DI,看看差异是存在于所有层,还是只存在于某个层。只有后者才更可能指向模型或特征层面的问题。
4. 建立可复用的公平性评估流程
4.1 先确定数据契约再计算
任何公平性指标都应建立在一份明确的数据契约上。建议在评估前记录以下内容:
- 模型名称与版本。
- 评估数据集来源与时间窗口。
- 正样本定义(例如“放款通过”“优惠券点击”“调用成功”)。
- 受保护组字段和取值。
- 参考组字段和取值。
- 决策阈值。
- 样本量要求。
这些字段不需要全部展示在报告里,但必须写进模型卡或评估配置文件中。否则三个月后复查同一份模型,很可能发现 DI 对不上,因为当时用的阈值或样本过滤条件已经没人记得了。
4.2 用多指标报告代替单点判断
只报告一个 DI 很容易引发争论。更实用的做法是同时输出一组基础指标,让评审者看到“通过率差异”之外的信息。
| 指标 | 计算方式 | 在报告中解决什么问题 |
|---|---|---|
| 各组样本量 | 分组计数 | 判断结论是否可信 |
| 各组通过率 | 正类数量 / 组内样本量 | 展示差异幅度 |
| DI | 受保护组通过率 / 参考组通过率 | 快速发现异常比例 |
| 统计显著性 | 置换检验或卡方检验 | 判断差异是否可能是随机波动 |
| 特征分布差异 | 特征均值、分位数对比 | 定位差异来源 |
报告里应该把 DI 放在“提示”位置,而不是“结论”位置。结论部分要回答三个具体问题:差异是否存在、差异有多大、差异是否来源于数据或业务结构。
4.3 通过置换检验判断差异是否显著
Bootstrap 给出置信区间,置换检验则帮助回答“如果分组和结果没有关系,观察到当前差异的概率是多少”。
def permutation_test(df: pd.DataFrame, group_col: str, positive_col: str, n_iter: int = 5000, seed: int = 42) -> float: rng = np.random.default_rng(seed) observed = disparate_impact(df, group_col, positive_col, 'A', 'B') count = 0 for _ in range(n_iter): shuffled = df.copy() shuffled[group_col] = rng.permutation(shuffled[group_col].values) permuted = disparate_impact(shuffled, group_col, positive_col, 'A', 'B') if permuted <= observed: count += 1 return count / n_iterp 值表示在“分组与结果无关”的原假设下,看到当前水平差异的概率。如果样本量很小,p 值可能并不显著,这时更应该谨慎下结论。
4.4 所有结果写入模型卡
模型卡不需要写成长文档,只要把关键上下文固化下来即可。下面是一份 YAML 示例:
model: name: credit_score_v2 version: 2025.06.01 evaluation: dataset: apply_2025_05 threshold: 0.5 positive_label: approved group_col: city_tier protected: tier_3 reference: tier_1 disparate_impact: value: 0.76 ci_lower: 0.71 ci_upper: 0.81 sample_size_protected: 3200 sample_size_reference: 12000 permutation_pvalue: 0.002 review: conclusion: need_deeper_analysis owner: ml_platform_team有了这份文件,后续任何一个同学都可以复现当天看到的结果。生产环境里,模型卡应该随着数据集版本和模型版本一起归档。
5. 从评估到决策:什么时候该调整模型
5.1 区分统计差异与业务合理差异
当 DI 低于 0.8 时,不要立刻想着调整阈值或换特征。第一步是把差异拆开看。
常见的做法是加入业务分层变量,例如城市等级、收入区间、渠道来源。如果 DI 在每个层内都普遍偏低,说明差异可能是系统性的。如果只在某个层内偏低,比如只有“低收入且有夜间申请记录”的用户通过率低,那么问题可能来自该层的数据质量或特征覆盖不足。
还可以查看特征重要性分布。如果某个强特征本身与分组高度相关,比如“是否拥有房产证”和城市等级强相关,那么剔除该特征不一定能消除差异,反而可能削弱模型效果。工程上要做的是解释特征对差异的贡献,而不是简单地删特征。
5.2 不要一上来就移动决策阈值
一些团队为了把 DI 拉回 0.8,会直接调整分组阈值,比如给受保护组降低通过线。这样做的风险很大:它可能只是提高了表面上的通过率,并没有改变模型内部学到的风险排序,还可能让高风险用户进入后续环节。
更值得尝试的方向有三类:
- 样本重加权:在训练时提高受保护组样本权重,让模型对分组差异更敏感。
- 特征工程修正:去掉数据收集偏差较大的特征,或者加入能解释业务合理差异的上下文特征。
- 后处理约束:在部署层对输出概率做分组约束,但必须评估对整体业务指标的影响。
每一种方法都要以实验的方式验证,不能只盯着 DI 一个指标看,还要观察准确率、召回率、线上转化率、坏账率等业务结果。
5.3 生产环境要做持续监控
离线评估通过不代表线上永远安全。用户结构会变化,数据分布会漂移,业务规则也会调整。上线后的监控至少应该覆盖:
- 参与评估的分组样本量是否显著下降。
- DI 随时间是否出现趋势性变化。
- 模型输出概率分布是否整体偏移。
- 线上人工审核结果和模型预测是否背离。
监控指标可以接入现有的特征平台和指标报警系统,一旦 DI 的日波动超过历史置信区间,就自动触发告警,让算法工程师回溯数据变更和特征版本。
6. 常见问题与排查清单
6.1 常见问题排查表
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| DI 一直低于 0.8 | 决策阈值设置不统一 | 检查评估代码里的 threshold 是否与线上一致 | 固定阈值,并记录在模型卡中 |
| 分组样本量差异过大 | 受保护组样本太少 | 打印分组计数和正类样本数 | 延长观察周期或采用分层抽样 |
| DI 在训练和测试集差异大 | 数据拼接或时间窗口不一致 | 对比两份数据集的用户分布 | 统一特征取值逻辑和日期筛选条件 |
| 同一模型两次评估结果不同 | 随机种子未固定 | 检查是否有随机采样和未固定 seed | 固定种子并保留生成数据的脚本 |
| 线上通过率和离线评估不一致 | 线上决策逻辑不同 | 对比离线阈值、规则和后处理逻辑 | 将线上决策函数沉淀为离线可调用的公共模块 |
6.2 排查顺序建议
遇到 DI 异常时,按下面的顺序定位:
- 确认数据时间窗口和用户过滤条件一致。
- 确认正样本定义一致。
- 确认决策阈值一致。
- 确认分组字段的缺失值和脏数据已处理。
- 计算各组样本量和正类样本数,排除小样本干扰。
- 加入置信区间和置换检验,判断差异是否显著。
- 分层交叉验证,定位差异来源。
- 回归到业务方确认差异是否有合理性解释。
这八个步骤可以避免大多数“指标报警但找不到原因”的现场问题。
7. 最佳实践与扩展方向
单一 DI 不应当成为模型评审的一票否决项。它更适合作为触发深入分析的前置信号。真正可靠的公平性评估体系,要同时具备稳定的数据契约、多指标视图、显著性检验和业务解释流程。
在日常项目中,建议把以下几点固化为团队规范:
- 所有公平性指标计算脚本统一版本,不允许每人在本地各写一份。
- 评估数据集和模型版本必须一一对应。
- 报告里至少包含样本量、置信区间、决策阈值和置换检验结果。
- 业务方需要参与差异结论的解释,算法团队不能单独下最终判断。
- 模型卡随模型上线发布,后续监控指标变化时同步更新。
扩展方向上,可以继续学习 Equalized Odds、Calibration 等更细粒度的公平性指标,也可以研究因果推断方法,分析“如果改变某个特征,结果会如何变化”。对于准备把公平性评估做成平台能力的团队,还可以把 DI 计算、置信区间、监控告警和模型卡生成封装成通用服务,让不同业务线复用同一套评估逻辑。
DI 的作用不是让所有模型都是坏的,而是让团队在差异出现时能迅速、可复现地定位原因。只要把采样、阈值、置信区间和业务分层纳入评估流程,原本让人头疼的“默认不通过”就会变成一个能被解释、能处理、能闭环的工程问题。