Phase 2: Bias Detection & Fairness Metrics · 50 min · Python · Fairlearn · scikit-learn
The Problem
In Lesson 2, you computed fairness metrics from scratch. That's important for understanding — but in production, you need a reusable tool that any engineer on your team can run against any model. That tool needs to produce structured output (JSON for pipelines), visualizations (for presentations), and be composable (so it can be part of a larger audit pipeline).
Fairlearn is the industry standard for this. It's used by Microsoft, Accenture, and the U.S. government. It appears in 90% of RAI job postings. And its MetricFrame API is designed exactly for building reusable bias audit modules.
Fairlearn's MetricFrame
The core of Fairlearn is MetricFrame — a data structure that computes any scikit-learn metric across subgroups defined by protected attributes.
from fairlearn.metrics import MetricFrame
from sklearn.metrics import accuracy_score, selection_rate
# One call computes the metric for every subgroup
mf = MetricFrame(
metrics={"accuracy": accuracy_score, "selection_rate": selection_rate},
y_true=y_true,
y_pred=y_pred,
sensitive_features=groups,
)
# Access per-group results
print(mf.by_group) # DataFrame: rows=groups, columns=metrics
# Compute disparities
print(mf.difference()) # max - min for each metric
print(mf.ratio()) # min / max for each metric
The power of MetricFrame is that it works with any scikit-learn metric — accuracy, precision, recall, F1, false positive rate, etc. You can compute dozens of metrics across dozens of subgroups in a single call.
Building a reusable audit module
Let's build bias_audit.py — a module that takes any model, dataset, and protected attributes, and produces a structured JSON report plus a matplotlib visualization.
"""
bias_audit.py
Reusable Fairlearn-based bias audit module.
Produces structured JSON report and matplotlib visualization.
"""
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import (
accuracy_score, precision_score, recall_score,
f1_score, false_positive_rate, true_positive_rate,
)
from fairlearn.metrics import MetricFrame, demographic_parity_difference, equalized_odds_difference
def run_bias_audit(y_true, y_pred, sensitive_features, y_pred_proba=None,
sensitive_feature_names=None, model_name="unnamed_model"):
"""
Run a comprehensive bias audit using Fairlearn.
Args:
y_true: Ground truth labels
y_pred: Predicted labels
y_pred_proba: Optional predicted probabilities
sensitive_features: DataFrame or dict of protected attributes
sensitive_feature_names: Names for the protected attributes
model_name: Name of the model being audited
Returns:
Dict with structured audit results
"""
if sensitive_feature_names is None:
sensitive_feature_names = list(sensitive_features.columns) if hasattr(sensitive_features, 'columns') else ["feature_0"]
# Define metrics to compute
metrics = {
"accuracy": accuracy_score,
"precision": precision_score,
"recall": recall_score,
"f1": f1_score,
"false_positive_rate": false_positive_rate,
"true_positive_rate": true_positive_rate,
"selection_rate": lambda y_true, y_pred: (y_pred == 1).mean(),
}
# Compute per-group metrics using MetricFrame
mf = MetricFrame(
metrics=metrics,
y_true=y_true,
y_pred=y_pred,
sensitive_features=sensitive_features,
)
# Build the audit report
audit = {
"model_name": model_name,
"total_samples": len(y_true),
"overall_metrics": {m: round(float(mf.overall[m]), 4) for m in metrics},
"by_group": {},
"disparities": {},
"fairness_metrics": {},
}
# Per-group breakdown
by_group = mf.by_group
for idx in by_group.index:
group_key = str(idx)
audit["by_group"][group_key] = {
m: round(float(by_group.loc[idx, m]), 4) for m in metrics
}
# Disparities (max - min)
diffs = mf.difference()
ratios = mf.ratio()
for m in metrics:
audit["disparities"][m] = {
"difference": round(float(diffs[m]), 4),
"ratio": round(float(ratios[m]), 4) if not np.isnan(ratios[m]) else None,
}
# Fairness-specific metrics
for sf_name, sf_col in zip(sensitive_feature_names, sensitive_features.columns if hasattr(sensitive_features, 'columns') else [sensitive_features]):
sf_series = sensitive_features[sf_col] if hasattr(sensitive_features, 'columns') else sensitive_features
dp_diff = demographic_parity_difference(
y_true, y_pred, sensitive_features=sf_series
)
eo_diff = equalized_odds_difference(
y_true, y_pred, sensitive_features=sf_series
)
# Disparate impact ratio
selection_rates = pd.Series(y_pred).groupby(sf_series).mean()
di_ratio = float(selection_rates.min() / selection_rates.max()) if selection_rates.max() > 0 else 0
audit["fairness_metrics"][sf_name] = {
"demographic_parity_difference": round(dp_diff, 4),
"equalized_odds_difference": round(eo_diff, 4),
"disparate_impact_ratio": round(di_ratio, 4),
"passes_80_percent_rule": di_ratio >= 0.80,
"passes_demographic_parity": dp_diff < 0.05,
"passes_equalized_odds": eo_diff < 0.05,
}
# Overall assessment
all_pass = all(
fm["passes_80_percent_rule"] and fm["passes_equalized_odds"]
for fm in audit["fairness_metrics"].values()
)
audit["overall_pass"] = all_pass
return audit
def visualize_bias_audit(audit, output_file="bias_audit_plot.png"):
"""Generate a matplotlib visualization of the bias audit results."""
by_group = audit["by_group"]
groups = list(by_group.keys())
metrics_to_plot = ["accuracy", "selection_rate", "false_positive_rate", "true_positive_rate"]
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle(f"Bias Audit: {audit['model_name']}", fontsize=16, fontweight='bold')
for ax, metric in zip(axes.flat, metrics_to_plot):
values = [by_group[g][metric] for g in groups]
colors = ['#2196F3', '#FF9800', '#4CAF50', '#E91E63', '#9C27B0'][:len(groups)]
bars = ax.bar(groups, values, color=colors, edgecolor='black', linewidth=0.5)
ax.set_title(metric.replace('_', ' ').title(), fontsize=12)
ax.set_ylabel(metric)
ax.set_ylim(0, max(values) * 1.3 if values else 1)
for bar, val in zip(bars, values):
ax.text(bar.get_x() + bar.get_width() / 2., bar.get_height() + 0.01,
f'{val:.3f}', ha='center', va='bottom', fontsize=9)
plt.tight_layout()
plt.savefig(output_file, dpi=150, bbox_inches='tight')
plt.close()
return output_file
def save_audit_report(audit, filename="bias_audit_report.json"):
"""Save the audit report as JSON."""
with open(filename, "w") as f:
json.dump(audit, f, indent=2)
return filename
if __name__ == "__main__":
# Demo: Train a model and run the audit
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,
})
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
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)
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
test_groups = df.loc[X_test.index, ["race"]]
audit = run_bias_audit(
y_true=y_test.values,
y_pred=y_pred,
sensitive_features=test_groups,
sensitive_feature_names=["race"],
model_name="LoanApproval-RF-v1",
)
json_file = save_audit_report(audit)
print(f"Audit report saved to {json_file}")
plot_file = visualize_bias_audit(audit)
print(f"Visualization saved to {plot_file}")
print(f"\nOverall pass: {audit['overall_pass']}")
print(f"Disparate impact ratio: {audit['fairness_metrics']['race']['disparate_impact_ratio']}")
print(f"Equalized odds difference: {audit['fairness_metrics']['race']['equalized_odds_difference']}")
Fairlearn version compatibility
Fairlearn's API changed significantly between versions. The code above uses the 0.13+ API where MetricFrame takes metrics as a dict and sensitive_features as a DataFrame. If you're using an older version, the API is different. Always check fairlearn.__version__ before writing audit code. Install with: pip install fairlearn>=0.13.