把偏见换到公平时踩过的坑

招聘筛选模型跑起来之后,整体准确率 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!