Phase 2: Bias Detection & Fairness Metrics · 50 min · Python · Fairlearn · AIF360
The Problem
You've run the bias audit. The numbers show disparate impact below 0.80 and equalized odds difference above 0.05. The model is biased. Now what?
Bias mitigation is where RAI engineering gets real. You have three intervention points — before training (pre-processing), during training (in-processing), and after training (post-processing). Each has tradeoffs:
| Intervention | When | What It Changes | Pros | Cons |
|---|
| Pre-processing | Before training | The training data | Simple, model-agnostic, preserves the original model | Doesn't fix model-internal bias; limited effectiveness |
| In-processing | During training | The training objective | Most effective; directly optimizes for fairness | Model-specific; harder to implement; may significantly reduce accuracy |
| Post-processing | After training | The decision threshold | Simple, model-agnostic, no retraining needed | Doesn't fix the model; may be legally problematic (explicitly using protected attribute at decision time) |
The 3 techniques you'll build
1. Reweighing (Pre-processing)
Reweighing assigns weights to training examples so that the joint distribution of the protected attribute and the label matches what it would be if they were independent. In simpler terms: it upweights under-represented combinations (e.g., Group_B with positive outcome) and downweights over-represented ones.
from fairlearn.preprocessing import CorrelationRemover
# Or use AIF360's Reweighing algorithm:
from aif360.algorithms.preprocessing import Reweighing
2. Exponentiated Gradient Reduction (In-processing)
This technique wraps your model in a fairness-constrained optimization. It trains the model while penalizing fairness violations, finding the best accuracy-fairness tradeoff on the Pareto frontier.
from fairlearn.reductions import ExponentiatedGradient, DemographicParity, EqualizedOdds
mitigator = ExponentiatedGradient(
estimator=RandomForestClassifier(),
constraints=EqualizedOdds(), # or DemographicParity()
)
mitigator.fit(X_train, y_train, sensitive_features=sensitive_train)
y_pred_mitigated = mitigator.predict(X_test)
3. Threshold Optimization (Post-processing)
This technique finds different decision thresholds for each group to equalize a fairness metric. It's the simplest to implement but the most legally sensitive — it explicitly uses the protected attribute at decision time.
from fairlearn.postprocessing import ThresholdOptimizer
postprocess = ThresholdOptimizer(
estimator=model, # pre-trained model
constraints="equalized_odds",
)
postprocess.fit(X_train, y_train, sensitive_features=sensitive_train)
y_pred_post = postprocess.predict(X_test, sensitive_features=sensitive_test)
Building the mitigation comparison module
Let's build a module that applies all 3 techniques, measures accuracy and fairness for each, and produces a tradeoff plot.
"""
mitigation_comparison.py
Applies 3 bias mitigation techniques and compares accuracy-fairness tradeoffs.
"""
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
# Import the bias audit from Lesson 3
from bias_audit import run_bias_audit
def apply_reweighing(X_train, y_train, sensitive_train):
"""Apply reweighing (pre-processing) using Fairlearn."""
from fairlearn.reductions import ExponentiatedGradient, EqualizedOdds
# Reweighing is implemented as a pre-processing step in AIF360
# Here we use Fairlearn's approach: train with sample weights
# that account for group imbalance
try:
from aif360.algorithms.preprocessing import Reweighing
from aif360.datasets import BinaryLabelDataset
# AIF360 approach
return None # Placeholder — actual implementation below
except ImportError:
# Fallback: manual reweighing
groups = sensitive_train.iloc[:, 0] if hasattr(sensitive_train, 'iloc') else sensitive_train
y_series = pd.Series(y_train)
# Compute weights: w = P(A) * P(Y) / P(A, Y)
n = len(y_train)
p_a = groups.value_counts(normalize=True).to_dict()
p_y = y_series.value_counts(normalize=True).to_dict()
weights = np.ones(n)
for i in range(n):
a = groups.iloc[i] if hasattr(groups, 'iloc') else groups[i]
y = y_train[i]
p_ay = ((groups == a) & (y_series == y)).sum() / n
p_a_val = p_a.get(a, 0)
p_y_val = p_y.get(y, 0)
if p_ay > 0:
weights[i] = (p_a_val * p_y_val) / p_ay
return weights
def apply_exponentiated_gradient(X_train, y_train, sensitive_train, constraint="equalized_odds"):
"""Apply exponentiated gradient reduction (in-processing) using Fairlearn."""
from fairlearn.reductions import ExponentiatedGradient, EqualizedOdds, DemographicParity
constraints = EqualizedOdds() if constraint == "equalized_odds" else DemographicParity()
mitigator = ExponentiatedGradient(
estimator=RandomForestClassifier(n_estimators=50, random_state=42),
constraints=constraints,
)
sensitive_series = sensitive_train.iloc[:, 0] if hasattr(sensitive_train, 'iloc') else sensitive_train
mitigator.fit(X_train, y_train, sensitive_features=sensitive_series)
return mitigator
def apply_threshold_optimization(model, X_train, y_train, sensitive_train):
"""Apply threshold optimization (post-processing) using Fairlearn."""
from fairlearn.postprocessing import ThresholdOptimizer
postprocess = ThresholdOptimizer(
estimator=model,
constraints="equalized_odds",
predict_method="predict",
)
sensitive_series = sensitive_train.iloc[:, 0] if hasattr(sensitive_train, 'iloc') else sensitive_train
postprocess.fit(X_train, y_train, sensitive_features=sensitive_series)
return postprocess
def compare_mitigations(X_train, X_test, y_train, y_test,
sensitive_train, sensitive_test,
sensitive_feature_names=None):
"""Apply all 3 mitigation techniques and compare results."""
if sensitive_feature_names is None:
sensitive_feature_names = ["protected"]
results = {}
# Baseline: no mitigation
base_model = RandomForestClassifier(n_estimators=100, random_state=42)
base_model.fit(X_train, y_train)
y_pred_base = base_model.predict(X_test)
base_audit = run_bias_audit(
y_test, y_pred_base, sensitive_features=sensitive_test,
sensitive_feature_names=sensitive_feature_names, model_name="baseline"
)
results["baseline"] = {
"accuracy": round(accuracy_score(y_test, y_pred_base), 4),
"fairness": base_audit["fairness_metrics"],
}
# 1. Reweighing (pre-processing)
try:
weights = apply_reweighing(X_train, y_train, sensitive_train)
if weights is not None:
rw_model = RandomForestClassifier(n_estimators=100, random_state=42)
rw_model.fit(X_train, y_train, sample_weight=weights)
y_pred_rw = rw_model.predict(X_test)
rw_audit = run_bias_audit(
y_test, y_pred_rw, sensitive_features=sensitive_test,
sensitive_feature_names=sensitive_feature_names, model_name="reweighing"
)
results["reweighing"] = {
"accuracy": round(accuracy_score(y_test, y_pred_rw), 4),
"fairness": rw_audit["fairness_metrics"],
}
except Exception as e:
results["reweighing"] = {"error": str(e)}
# 2. Exponentiated Gradient (in-processing)
try:
eg_model = apply_exponentiated_gradient(X_train, y_train, sensitive_train)
y_pred_eg = eg_model.predict(X_test)
eg_audit = run_bias_audit(
y_test, y_pred_eg, sensitive_features=sensitive_test,
sensitive_feature_names=sensitive_feature_names, model_name="exp_gradient"
)
results["exp_gradient"] = {
"accuracy": round(accuracy_score(y_test, y_pred_eg), 4),
"fairness": eg_audit["fairness_metrics"],
}
except Exception as e:
results["exp_gradient"] = {"error": str(e)}
# 3. Threshold Optimization (post-processing)
try:
threshold_model = apply_threshold_optimization(
base_model, X_train, y_train, sensitive_train
)
sensitive_test_series = sensitive_test.iloc[:, 0] if hasattr(sensitive_test, 'iloc') else sensitive_test
y_pred_to = threshold_model.predict(X_test, sensitive_features=sensitive_test_series)
to_audit = run_bias_audit(
y_test, y_pred_to, sensitive_features=sensitive_test,
sensitive_feature_names=sensitive_feature_names, model_name="threshold_opt"
)
results["threshold_opt"] = {
"accuracy": round(accuracy_score(y_test, y_pred_to), 4),
"fairness": to_audit["fairness_metrics"],
}
except Exception as e:
results["threshold_opt"] = {"error": str(e)}
return results
def plot_tradeoff(results, output_file="mitigation_tradeoff.png"):
"""Plot the accuracy-fairness tradeoff across mitigation techniques."""
techniques = []
accuracies = []
di_ratios = []
eo_diffs = []
for name, data in results.items():
if "error" in data:
continue
for attr, fm in data.get("fairness", {}).items():
techniques.append(f"{name}\n({attr})")
accuracies.append(data["accuracy"])
di_ratios.append(fm.get("disparate_impact_ratio", 0))
eo_diffs.append(fm.get("equalized_odds_difference", 1))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
# Accuracy vs DI Ratio
ax1.scatter(di_ratios, accuracies, s=100, c=['#2196F3', '#FF9800', '#4CAF50', '#E91E63'][:len(techniques)], zorder=5)
for i, tech in enumerate(techniques):
ax1.annotate(tech, (di_ratios[i], accuracies[i]), fontsize=8,
xytext=(5, 5), textcoords='offset points')
ax1.axvline(x=0.80, color='red', linestyle='--', label='80% rule threshold')
ax1.set_xlabel('Disparate Impact Ratio')
ax1.set_ylabel('Accuracy')
ax1.set_title('Accuracy vs. Disparate Impact Ratio')
ax1.legend()
# Accuracy vs Equalized Odds Difference
ax2.scatter(eo_diffs, accuracies, s=100, c=['#2196F3', '#FF9800', '#4CAF50', '#E91E63'][:len(techniques)], zorder=5)
for i, tech in enumerate(techniques):
ax2.annotate(tech, (eo_diffs[i], accuracies[i]), fontsize=8,
xytext=(5, 5), textcoords='offset points')
ax2.axvline(x=0.05, color='red', linestyle='--', label='0.05 threshold')
ax2.set_xlabel('Equalized Odds Difference')
ax2.set_ylabel('Accuracy')
ax2.set_title('Accuracy vs. Equalized Odds Difference')
ax2.legend()
plt.suptitle('Bias Mitigation Tradeoff Analysis', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig(output_file, dpi=150, bbox_inches='tight')
plt.close()
return output_file
if __name__ == "__main__":
# Generate synthetic data
np.random.seed(42)
n = 3000
race = np.random.choice(["Group_A", "Group_B"], n, p=[0.6, 0.4])
income = np.where(race == "Group_A", np.random.normal(65000, 20000, n),
np.random.normal(48000, 18000, n))
credit_score = np.where(race == "Group_A", np.random.normal(720, 80, n),
np.random.normal(680, 90, n))
repaid = np.where(race == "Group_A", np.random.binomial(1, 0.85, n),
np.random.binomial(1, 0.70, n))
df = pd.DataFrame({
"race": race,
"income": income.clip(15000, 200000).astype(int),
"credit_score": credit_score.clip(300, 850).astype(int),
"repaid": repaid,
})
X = df[["income", "credit_score"]]
y = df["repaid"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
sensitive_train = df.loc[X_train.index, ["race"]]
sensitive_test = df.loc[X_test.index, ["race"]]
print("Running mitigation comparison...")
results = compare_mitigations(
X_train, X_test, y_train, y_test,
sensitive_train, sensitive_test,
sensitive_feature_names=["race"],
)
with open("mitigation_comparison.json", "w") as f:
json.dump(results, f, indent=2)
print("Results saved to mitigation_comparison.json")
plot_file = plot_tradeoff(results)
print(f"Tradeoff plot saved to {plot_file}")
print("\nSummary:")
for name, data in results.items():
if "error" in data:
print(f" {name}: ERROR — {data['error'][:60]}")
else:
attr = list(data["fairness"].keys())[0]
fm = data["fairness"][attr]
print(f" {name}: accuracy={data['accuracy']:.2%}, DI={fm['disparate_impact_ratio']:.3f}, EO_diff={fm['equalized_odds_difference']:.4f}")
After running all 3 mitigation techniques, you find that threshold optimization achieves the best fairness (DI ratio = 0.92, EO diff = 0.02) but reduces accuracy by 4%. Exponentiated gradients achieve moderate fairness (DI = 0.81, EO diff = 0.04) with only 1% accuracy loss. Which do you recommend to the risk committee, and why?
This is a classic RAI engineering decision. Threshold optimization gets better fairness numbers, but it achieves them by explicitly using race at decision time — setting different thresholds for different racial groups. While the intent is anti-discriminatory, the mechanism is racially explicit, which creates legal exposure in jurisdictions that prohibit considering protected attributes in decisions (even for benign purposes). Exponentiated gradients achieve fairness during training, so the deployed model doesn't need to know the user's race at inference time — it's already fair. The 1% accuracy cost is the price of fairness, and it's a much easier sell to both the business and legal teams. Always present the tradeoffs with legal context, not just numbers.