把偏见换到公平时踩过的坑
招聘筛选模型跑起来之后,整体准确率 84%,男女分组一拆,差距立刻露出来。公平性指标能算,约束能加,难的是选哪个指标、接受多少性能损失。
这篇只记真正影响落地结果的部分。
为什么要关注公平性
先讲个具体场景。假设你在做一个招聘筛选模型,训练数据来自历年招聘记录。这个模型很快会学会一些"聪明"的规则:来自某些学校背景的候选人通过率更高,某些年龄段的面试成功率更低,某些地区的候选人更频繁被标记"高风险"。从统计角度看,模型没有错——它忠实地学到了历史数据中的规律。问题在于,这些规律里可能包含了历史偏见。
举个例子,用 Python 加载一个招聘数据集:
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report
# 假设的数据集
df = pd.read_csv('recruitment_data.csv')
# 特征和标签
features = ['age', 'education_level', 'experience_years', 'region']
sensitive_feature = 'gender' # 敏感属性
target = 'hired'
# 训练模型
X_train, X_test, y_train, y_test = train_test_split(
df[features], df[target], test_size=0.3, random_state=42
)
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
# 评估
y_pred = model.predict(X_test)
print(f"Overall Accuracy: {accuracy_score(y_test, y_pred):.3f}")
# 按性别细分
for gender in df[sensitive_feature].unique():
mask = X_test.index.isin(df[df[sensitive_feature] == gender].index)
gender_acc = accuracy_score(y_test[mask], y_pred[mask])
print(f"{gender} Accuracy: {gender_acc:.3f}")
跑下来可能出现类似结果:
Overall Accuracy: 0.842
Male Accuracy: 0.868
Female Accuracy: 0.815
整体准确率看着不错,但男女之间有明显差距。这就是公平性问题开始浮现的地方:技术上模型达到了目标,但在社会意义上它可能在放大既有偏见。
偏见从哪里来
理解偏见来源比直接"修复"更重要,因为不同来源需要不同的处理方式。
数据层面的偏见
最直接的是训练数据本身就有问题。比如招聘数据中,某些群体在历史上就很少被录用,模型会把这个"历史事实"当成"规律"学进去。
# 检查训练数据的分布
print("Training data distribution:")
print(df.groupby([sensitive_feature, target]).size() / len(df))
# 可能看到类似结果:
# gender hired
# F 0 0.68
# 1 0.12
# M 0 0.15
# 1 0.05
这种情况下,模型学到的不是"谁更优秀",而是"谁历史上更可能被录用"。
特征层面的偏见
有时候问题不在于显性的敏感特征,而在于代理特征(proxy features)。比如模型没有直接用种族,但用了邮政编码,而某些邮政编码和种族高度相关。
import numpy as np
from scipy.stats import pearsonr
# 检查邮政编码和种族的相关性
# 假设有一个外部数据集把邮政编码映射到种族分布
zipcode_race = pd.read_csv('zipcode_demographics.csv')
corr, _ = pearsonr(df['zipcode'], zipcode_race['majority_race_percent'])
print(f"Correlation between zipcode and race composition: {corr:.3f}")
如果相关系数很高,模型实际上还是在用种族做决策,只是绕了个弯。
模型层面的偏见
某些模型架构本身就会加剧偏见。深度学习模型可能会学到数据中细微的模式关联,这些关联有时反映了偏见但很难解释。
from sklearn.linear_model import LogisticRegression
# 比较不同模型
lr_model = LogisticRegression(max_iter=1000, random_state=42)
rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
models = {'Logistic Regression': lr_model, 'Random Forest': rf_model}
for name, model in models.items():
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
# 计算不同群体的准确率差异
group_accs = []
for gender in df[sensitive_feature].unique():
mask = X_test.index.isin(df[df[sensitive_feature] == gender].index)
acc = accuracy_score(y_test[mask], y_pred[mask])
group_accs.append(acc)
disparity = max(group_accs) - min(group_accs)
print(f"{name}: Overall Acc={accuracy_score(y_test, y_pred):.3f}, Disparity={disparity:.3f}")
复杂模型可能准确率更高,但群体间差距也可能更大。
如何检测偏见
检测偏见是第一步,也是最容易被忽视的一步。我试过几个工具和方法,各有优劣。
统计差异分析
最简单的就是算一算不同群体的关键指标差异:
def calculate_fairness_metrics(df, predictions, sensitive_feature, target):
metrics = {}
for group in df[sensitive_feature].unique():
group_mask = df[sensitive_feature] == group
y_true = df[target][group_mask]
y_pred = predictions[group_mask]
# 基本分类指标
tp = ((y_true == 1) & (y_pred == 1)).sum()
tn = ((y_true == 0) & (y_pred == 0)).sum()
fp = ((y_true == 0) & (y_pred == 1)).sum()
fn = ((y_true == 1) & (y_pred == 0)).sum()
metrics[group] = {
'accuracy': (tp + tn) / len(y_true),
'true_positive_rate': tp / (tp + fn) if (tp + fn) > 0 else 0,
'false_positive_rate': fp / (fp + tn) if (fp + tn) > 0 else 0,
'positive_predictive_value': tp / (tp + fp) if (tp + fp) > 0 else 0
}
return pd.DataFrame(metrics).T
fairness_df = calculate_fairness_metrics(
df, pd.Series(model.predict(X_test), index=X_test.index),
sensitive_feature, target
)
print(fairness_df)
这种方法简单直接,但只反映了表面的数字差异,不涉及"为什么"。
公平性指标体系
业界有一些比较成熟的公平性指标,我实际用过几个:
1. 统计均等(Statistical Parity)
要求不同群体的预测正类比例相等:
def statistical_parity(predictions, sensitive_attribute):
parity_dict = {}
for group in sensitive_attribute.unique():
group_mask = sensitive_attribute == group
group_preds = predictions[group_mask]
parity_dict[group] = (group_preds == 1).mean()
return parity_dict
parity = statistical_parity(pd.Series(y_pred, index=X_test.index),
df.loc[X_test.index, sensitive_feature])
print(f"Statistical Parity: {parity}")
2. 机会均等(Equal Opportunity)
要求真正需要帮助的人被模型正确识别的概率相等:
def equal_opportunity(y_true, y_pred, sensitive_attribute):
eo_dict = {}
for group in sensitive_attribute.unique():
group_mask = sensitive_attribute == group
group_y_true = y_true[group_mask]
group_y_pred = y_pred[group_mask]
tp = ((group_y_true == 1) & (group_y_pred == 1)).sum()
fn = ((group_y_true == 1) & (group_y_pred == 0)).sum()
eo_dict[group] = tp / (tp + fn) if (tp + fn) > 0 else 0
return eo_dict
eo = equal_opportunity(
pd.Series(y_test, index=X_test.index),
pd.Series(y_pred, index=X_test.index),
df.loc[X_test.index, sensitive_feature]
)
print(f"Equal Opportunity: {eo}")
3. 预测均等(Predictive Parity)
要求模型预测为正的样本中真正为正的比例相等:
def predictive_parity(y_true, y_pred, sensitive_attribute):
pp_dict = {}
for group in sensitive_attribute.unique():
group_mask = sensitive_attribute == group
group_y_true = y_true[group_mask]
group_y_pred = y_pred[group_mask]
tp = ((group_y_true == 1) & (group_y_pred == 1)).sum()
fp = ((group_y_true == 0) & (group_y_pred == 1)).sum()
pp_dict[group] = tp / (tp + fp) if (tp + fp) > 0 else 0
return pp_dict
pp = predictive_parity(
pd.Series(y_test, index=X_test.index),
pd.Series(y_pred, index=X_test.index),
df.loc[X_test.index, sensitive_feature]
)
print(f"Predictive Parity: {pp}")
这些指标之间往往存在权衡,不可能同时全部满足。实践中需要根据业务场景选择重点关注的指标。
使用专门工具
写了一轮自己造轮子的代码后,我试了几个现成的工具:
IBM AI Fairness 360 (AIF360)
from aif360.datasets import BinaryLabelDataset
from aif360.metrics import BinaryLabelDatasetMetric
from aif360.algorithms.preprocessing import Reweighing
# 转换数据格式
dataset = BinaryLabelDataset(
df=df,
label_name=target,
protected_attribute_names=[sensitive_feature],
favorable_label=1,
unfavorable_label=0
)
# 计算差异
metric = BinaryLabelDatasetMetric(
dataset,
unprivileged_groups=[{sensitive_feature: 'F'}],
privileged_groups=[{sensitive_feature: 'M'}]
)
print(f"Disparate Impact: {metric.disparate_impact():.3f}")
print(f"Mean Difference: {metric.mean_difference():.3f}")
Fairlearn
from fairlearn.metrics import MetricFrame
from sklearn.metrics import accuracy_score, recall_score
metrics = {
'accuracy': accuracy_score,
'recall': recall_score
}
metric_frame = MetricFrame(
metrics=metrics,
y_true=y_test,
y_pred=y_pred,
sensitive_features=df.loc[X_test.index, sensitive_feature]
)
print(metric_frame.by_group)
这些工具能节省不少时间,但关键还是理解指标背后的含义,以及选择哪个指标作为优化目标。
去偏见技术
检测完偏见后,下一个问题是怎么解决。我按"何时干预"分成三类,不同阶段方法不同。
预处理(Pre-processing)
在训练模型之前就对数据动手,让训练数据本身更公平。
重新加权(Reweighing)
给不同群体的样本不同权重,让训练数据在统计上更均衡:
from aif360.algorithms.preprocessing import Reweighing
# 应用重新加权
rw = Reweighing(
unprivileged_groups=[{sensitive_feature: 'F'}],
privileged_groups=[{sensitive_feature: 'M'}]
)
dataset_transformed = rw.fit_transform(dataset)
# 获取样本权重
sample_weights = dataset_transformed.instance_weights
# 用权重训练模型
model.fit(X_train, y_train, sample_weight=sample_weights[:len(X_train)])
采样重平衡
通过过采样或欠采样调整数据分布:
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
from imblearn.pipeline import Pipeline
# 对每个敏感群体分别重采样
resampling_strategy = {}
for group in df[sensitive_feature].unique():
group_df = df[df[sensitive_feature] == group]
majority_class = group_df[target].value_counts().idxmax()
minority_class = 1 - majority_class
# 少数类过采样,多数类欠采样
resampling_strategy[group] = {
majority_class: len(group_df) // 2, # 保留一半
minority_class: len(group_df) // 2 # 补齐到一半
}
# 应用重采样
resampler = SMOTE(sampling_strategy=resampling_strategy)
X_resampled, y_resampled = resampler.fit_resample(X_train, y_train)
这个方法我踩过坑:过度重采样会让模型过拟合,尤其是样本本来就不多的时候。
训练时干预(In-processing)
训练模型时就把公平性约束加进去。
添加公平性正则项
from sklearn.base import BaseEstimator, ClassifierMixin
import numpy as np
class FairLogisticRegression(BaseEstimator, ClassifierMixin):
def __init__(self, lambda_fair=0.1, max_iter=1000):
self.lambda_fair = lambda_fair
self.max_iter = max_iter
def fit(self, X, y, sensitive_attr):
# 添加公平性约束的逻辑回归
n_samples, n_features = X.shape
self.coef_ = np.zeros(n_features)
self.intercept_ = 0
learning_rate = 0.01
for _ in range(self.max_iter):
# 预测
y_pred = self._sigmoid(X @ self.coef_ + self.intercept_)
# 计算损失梯度(标准逻辑回归)
loss_grad = X.T @ (y_pred - y) / n_samples
# 计算公平性梯度
fair_grad = self._fairness_gradient(X, y_pred, sensitive_attr)
# 更新参数
self.coef_ -= learning_rate * (loss_grad + self.lambda_fair * fair_grad)
self.intercept_ -= learning_rate * np.mean(y_pred - y)
return self
def _fairness_gradient(self, X, y_pred, sensitive_attr):
# 计算统计均等的梯度
fair_grad = np.zeros(X.shape[1])
for group in sensitive_attr.unique():
group_mask = sensitive_attr == group
group_pred = y_pred[group_mask]
group_X = X[group_mask]
# 偏差
group_mean = group_pred.mean()
overall_mean = y_pred.mean()
# 梯度贡献
fair_grad += np.sum(group_X * (group_pred - overall_mean), axis=0)
return fair_grad / len(X)
def _sigmoid(self, z):
return 1 / (1 + np.exp(-z))
def predict(self, X):
return (self._sigmoid(X @ self.coef_ + self.intercept_) >= 0.5).astype(int)
# 使用
fair_model = FairLogisticRegression(lambda_fair=0.5)
fair_model.fit(X_train.values, y_train.values, df.loc[X_train.index, sensitive_feature])
这个方法我在风控项目里用过,效果还行,但有个明显问题:公平性参数怎么调?高了会牺牲准确率,低了又没什么作用。
约束优化
使用 Fairlearn 的约束优化器:
from fairlearn.reductions import ExponentiatedGradient, DemographicParity
from sklearn.linear_model import LogisticRegression
# 定义基础模型
base_model = LogisticRegression(max_iter=1000, random_state=42)
# 定义公平性约束
constraint = DemographicParity()
# 使用约束优化
mitigator = ExponentiatedGradient(
base_model,
constraint,
eps=0.01 # 允许的公平性偏差
)
mitigator.fit(X_train, y_train, sensitive_features=df.loc[X_train.index, sensitive_feature])
# 预测
y_pred_fair = mitigator.predict(X_test)
这个方法看起来更系统,但计算成本不低,而且需要明确"你能接受多大偏差"。
后处理(Post-processing)
模型训练完成后,调整输出阈值或决策规则。
不同群体不同阈值
def find_threshold_for_group(y_true, y_scores, target_fpr):
"""为特定群体找到满足目标误报率的阈值"""
fpr, tpr, thresholds = roc_curve(y_true, y_scores)
target_idx = np.argmin(np.abs(fpr - target_fpr))
return thresholds[target_idx]
# 为不同群体设置不同阈值
thresholds = {}
for group in df[sensitive_feature].unique():
group_mask = df.loc[X_test.index, sensitive_feature] == group
group_y_true = y_test[group_mask]
group_y_scores = model.predict_proba(X_test[group_mask])[:, 1]
thresholds[group] = find_threshold_for_group(group_y_true, group_y_scores, 0.1)
# 应用阈值
y_pred_fair = []
for idx, row in X_test.iterrows():
group = df.loc[idx, sensitive_feature]
score = model.predict_proba([row])[0, 1]
y_pred_fair.append(1 if score >= thresholds[group] else 0)
y_pred_fair = np.array(y_pred_fair)
这个方法最直接,但也有问题:不同阈值本身会不会被质疑为不公平?
实践中的取舍
指标和工具讲完了,落地时卡在取舍。真实业务里做公平性优化,技术能算清楚,难在业务和合规怎么定边界。
准确率 vs 公平性
这是最直接的冲突。加了公平性约束后,模型整体性能几乎肯定会下降。
# 比较公平性优化前后的模型
print("Original Model:")
print(f"Accuracy: {accuracy_score(y_test, y_pred):.3f}")
print(f"Fairness Disparity: {max(fairness_df['accuracy']) - min(fairness_df['accuracy']):.3f}")
print("\nFair Model:")
print(f"Accuracy: {accuracy_score(y_test, y_pred_fair):.3f}")
fairness_df_fair = calculate_fairness_metrics(
df, pd.Series(y_pred_fair, index=X_test.index),
sensitive_feature, target
)
print(f"Fairness Disparity: {max(fairness_df_fair['accuracy']) - min(fairness_df_fair['accuracy']):.3f}")
我见过的情况通常是这样:整体准确率从 85% 掉到 82%,公平性差异从 15% 收到 5%。值不值得?这得看业务和合规要求。金融领域通常愿意牺牲一点准确率换取合规,而推荐系统可能更看重点击率。
哪个公平性指标最重要
统计均等、机会均等、预测均等——每个听起来都合理,但它们经常冲突。
举个极端例子:如果真正需要帮助的群体在训练数据里本来就很少,机会均等可能要求模型在这个群体上更激进,这又会抬高误报率,破坏预测均等。
我现在的做法是先问业务:你最不能接受哪种不公平?是漏掉了应该帮助的人(机会均等),还是误判了不应该受惩罚的人(预测均等)?答案不一样,优化方向就完全不同。
定义边界
公平性不是无限追求,而是找到一个可接受的边界。
# 定义可接受的公平性阈值
MAX_DISPARITY_THRESHOLD = 0.05 # 5% 的群体间差距可接受
MIN_ACCURACY_THRESHOLD = 0.80 # 80% 的整体准确率是底线
# 评估模型是否满足约束
def check_constraints(y_true, y_pred, sensitive_attr):
fairness_df = calculate_fairness_metrics(
df, pd.Series(y_pred, index=X_test.index),
sensitive_feature, target
)
disparity = max(fairness_df['accuracy']) - min(fairness_df['accuracy'])
accuracy = accuracy_score(y_true, y_pred)
return {
'fairness_ok': disparity <= MAX_DISPARITY_THRESHOLD,
'accuracy_ok': accuracy >= MIN_ACCURACY_THRESHOLD,
'disparity': disparity,
'accuracy': accuracy
}
constraints = check_constraints(y_test, y_pred_fair, df.loc[X_test.index, sensitive_feature])
print(constraints)
有了这个边界,技术问题就变成了优化问题:在约束范围内找到最好的模型。
踩过的坑
说几个我在实践里踩过的坑,可能比理论更管用。
坑一:过度优化单一指标
有一次我死磕统计均等,硬是把不同群体的通过率差异降到了 1% 以下,结果业务方直接质疑:为什么模型在一些明显不靠谱的人身上给这么高的分?
原因很简单:强行追求统计均等会导致模型在弱势群体上过度补偿,反而可能伤害业务。后来学乖了,多个指标一起看,尤其关注最极端的那些案例。
坑二:忽视了代理特征
以为去掉显性的敏感特征就万事大吉,结果模型还是学到了类似效果。比如模型没用到种族,但学会了"某些教育背景 + 某些邮政编码"的组合等价于某个种族。
现在我会在训练前后都做一次特征重要性分析,看看有没有什么可疑的模式突然冒出来:
import shap
# 使用 SHAP 分析特征重要性
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
# 检查哪些特征对敏感群体影响最大
for group in df[sensitive_feature].unique():
group_mask = df.loc[X_test.index, sensitive_feature] == group
group_shap = shap_values[1][group_mask] # 正类的 SHAP 值
mean_shap = np.abs(group_shap).mean(axis=0)
top_features = X_test.columns[np.argsort(-mean_shap)][:5]
print(f"\nTop influential features for {group}:")
for feat in top_features:
print(f" {feat}: {mean_shap[X_test.columns.get_loc(feat)]:.3f}")
坑三:合规要求变化
刚开始做时合规要求是"群体间差异不超过 10%",做了半年突然变成"不超过 5%",模型全部要重做。现在学聪明了:一开始就把代码写得可扩展,公平性参数配置化。
# 配置化的公平性约束
FAIRNESS_CONFIG = {
'max_disparity': 0.05,
'min_accuracy': 0.80,
'sensitive_attributes': ['gender', 'age_group'],
'fairness_metrics': ['statistical_parity', 'equal_opportunity'],
'optimization_method': 'exponentiated_gradient'
}
# 使用配置训练模型
def train_fair_model(X, y, sensitive_attrs, config):
# 根据 config 选择方法和参数
# ...
pass
坑四:忘记持续监控
模型上线就完事了吗?没有。数据分布会变,用户行为会变,公平性状况也会变。现在我会定期跑一个监控脚本:
def monitor_fairness(model, new_data, config):
"""监控上线后的公平性状况"""
sensitive_attrs = config['sensitive_attributes']
results = {}
for attr in sensitive_attrs:
y_pred = model.predict(new_data[features])
fairness_df = calculate_fairness_metrics(
new_data, pd.Series(y_pred, index=new_data.index),
attr, target
)
disparity = max(fairness_df['accuracy']) - min(fairness_df['accuracy'])
results[attr] = {
'disparity': disparity,
'threshold': config['max_disparity'],
'alert': disparity > config['max_disparity']
}
return results
# 定期运行
monitoring_results = monitor_fairness(production_model, latest_data, FAIRNESS_CONFIG)
print(monitoring_results)
一点想法
公平性这块,我现在的做法比较务实:模型负责把差异量化出来,业务方定可接受边界,工程侧做检测、约束和监控。指望算法单独"解决"公平性,不现实;完全不管也过不去合规那关。
不同场景容忍度差很多——信贷、招聘、推荐用的指标和阈值不能照搬。成本也是硬约束:重采样、约束优化、持续监控都要算进项目预算里。
结尾
目前能稳定复用的流程就三步:训练前查数据分布和代理特征,训练时加公平性约束,上线后定期跑监控脚本。指标冲突时先问业务最不能接受哪种不公平,再定优化方向。
算法会把历史偏见学进去,也能把差异摊到台面上讨论。值判断还是得人来做,工程能做的是别让它藏在黑盒里。
版权声明: 本文首发于 指尖魔法屋-把偏见换到公平时踩过的坑(https://blog.thinkmoon.cn/post/180-ai-fairness-bias-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。