← Back to Table of Contents
Chapter 2

Fairness Metrics

Measuring fairness is fundamental to detecting and mitigating bias in AI systems. This chapter explores the mathematical foundations of fairness metrics, their applications, limitations, and practical implementation.

Introduction to Fairness Metrics

Before we can address bias, we must be able to measure it. Fairness metrics provide quantitative ways to assess whether an AI system treats different groups equitably. However, fairness is not a single monolithic concept—there are multiple, sometimes competing definitions of what constitutes fair treatment.

The Impossibility Theorem

Research has shown that except in trivial cases, it is mathematically impossible to satisfy all fairness criteria simultaneously. This means practitioners must make informed choices about which fairness definitions are most appropriate for their specific context and use case.

Group Fairness Metrics

Group fairness metrics compare outcomes across different demographic groups. These metrics typically focus on ensuring statistical parity or equal treatment between groups defined by protected attributes.

1. Demographic Parity (Statistical Parity)

Demographic parity requires that the proportion of positive predictions is equal across all groups. In other words, the decision should be independent of the protected attribute.

P(Ŷ = 1 | A = a) = P(Ŷ = 1 | A = b) for all groups a, b

Where Ŷ is the prediction and A is the protected attribute (e.g., race, gender).

# Implementation of Demographic Parity
import numpy as np

def demographic_parity(y_pred, protected_attribute):
    """
    Calculate demographic parity metric
    Returns ratio of minimum to maximum positive rate
    """
    groups = np.unique(protected_attribute)
    positive_rates = {}

    for group in groups:
        mask = protected_attribute == group
        positive_rate = np.mean(y_pred[mask])
        positive_rates[group] = positive_rate
        print(f"Group {group}: {positive_rate:.3f}")

    rates = list(positive_rates.values())
    parity_ratio = min(rates) / max(rates) if max(rates) > 0 else 1.0

    print(f"\nDemographic Parity Ratio: {parity_ratio:.3f}")
    print(f"Status: {'PASS' if parity_ratio >= 0.8 else 'FAIL'}")

    return parity_ratio, positive_rates

# Example usage
y_pred = np.array([1, 1, 0, 1, 0, 1, 0, 0, 1, 1])
protected = np.array(['A', 'A', 'A', 'A', 'A', 'B', 'B', 'B', 'B', 'B'])

ratio, rates = demographic_parity(y_pred, protected)

Advantages:

Limitations:

2. Equalized Odds

Equalized odds requires that both true positive rate (TPR) and false positive rate (FPR) are equal across groups. This ensures that the model's errors are distributed equally.

P(Ŷ = 1 | Y = y, A = a) = P(Ŷ = 1 | Y = y, A = b)
for y ∈ {0, 1} and all groups a, b
# Implementation of Equalized Odds
def equalized_odds(y_true, y_pred, protected_attribute):
    """
    Calculate True Positive Rate and False Positive Rate
    for each group to assess equalized odds
    """
    groups = np.unique(protected_attribute)
    results = {}

    for group in groups:
        mask = protected_attribute == group

        # True Positive Rate
        tp = np.sum((y_true[mask] == 1) & (y_pred[mask] == 1))
        fn = np.sum((y_true[mask] == 1) & (y_pred[mask] == 0))
        tpr = tp / (tp + fn) if (tp + fn) > 0 else 0

        # False Positive Rate
        fp = np.sum((y_true[mask] == 0) & (y_pred[mask] == 1))
        tn = np.sum((y_true[mask] == 0) & (y_pred[mask] == 0))
        fpr = fp / (fp + tn) if (fp + tn) > 0 else 0

        results[group] = {'tpr': tpr, 'fpr': fpr}
        print(f"Group {group}:")
        print(f"  TPR: {tpr:.3f}, FPR: {fpr:.3f}")

    # Check disparities
    tprs = [r['tpr'] for r in results.values()]
    fprs = [r['fpr'] for r in results.values()]

    tpr_disparity = max(tprs) - min(tprs)
    fpr_disparity = max(fprs) - min(fprs)

    print(f"\nTPR Disparity: {tpr_disparity:.3f}")
    print(f"FPR Disparity: {fpr_disparity:.3f}")
    print(f"Status: {'PASS' if (tpr_disparity < 0.1 and fpr_disparity < 0.1) else 'FAIL'}")

    return results

# Example usage
y_true = np.array([1, 1, 0, 1, 0, 0, 1, 0, 1, 0])
y_pred = np.array([1, 1, 0, 1, 0, 1, 1, 0, 0, 0])
protected = np.array(['A', 'A', 'A', 'A', 'A', 'B', 'B', 'B', 'B', 'B'])

results = equalized_odds(y_true, y_pred, protected)

Advantages:

Limitations:

3. Equal Opportunity

Equal opportunity is a relaxed version of equalized odds that only requires equal true positive rates across groups. This is particularly relevant when false negatives are more harmful than false positives.

P(Ŷ = 1 | Y = 1, A = a) = P(Ŷ = 1 | Y = 1, A = b)
# Implementation of Equal Opportunity
def equal_opportunity(y_true, y_pred, protected_attribute):
    """
    Calculate True Positive Rate for each group
    """
    groups = np.unique(protected_attribute)
    tprs = {}

    for group in groups:
        mask = protected_attribute == group
        positives = y_true[mask] == 1

        if positives.sum() == 0:
            tprs[group] = None
            print(f"Group {group}: No positive samples")
            continue

        tp = np.sum((y_true[mask] == 1) & (y_pred[mask] == 1))
        tpr = tp / positives.sum()
        tprs[group] = tpr
        print(f"Group {group} TPR: {tpr:.3f}")

    # Calculate disparity
    valid_tprs = [t for t in tprs.values() if t is not None]
    if len(valid_tprs) > 1:
        disparity = max(valid_tprs) - min(valid_tprs)
        print(f"\nTPR Disparity: {disparity:.3f}")
        print(f"Status: {'PASS' if disparity < 0.1 else 'FAIL'}")

    return tprs

4. Predictive Parity (Outcome Test)

Predictive parity requires that precision (positive predictive value) is equal across groups. This ensures that a positive prediction has the same meaning regardless of group membership.

P(Y = 1 | Ŷ = 1, A = a) = P(Y = 1 | Ŷ = 1, A = b)
# Implementation of Predictive Parity
def predictive_parity(y_true, y_pred, protected_attribute):
    """
    Calculate Precision (PPV) for each group
    """
    groups = np.unique(protected_attribute)
    precisions = {}

    for group in groups:
        mask = protected_attribute == group
        predicted_positive = y_pred[mask] == 1

        if predicted_positive.sum() == 0:
            precisions[group] = None
            continue

        tp = np.sum((y_true[mask] == 1) & (y_pred[mask] == 1))
        precision = tp / predicted_positive.sum()
        precisions[group] = precision
        print(f"Group {group} Precision: {precision:.3f}")

    # Check disparity
    valid_prec = [p for p in precisions.values() if p is not None]
    if len(valid_prec) > 1:
        disparity = max(valid_prec) - min(valid_prec)
        print(f"\nPrecision Disparity: {disparity:.3f}")
        print(f"Status: {'PASS' if disparity < 0.1 else 'FAIL'}")

    return precisions

Individual Fairness

Individual fairness takes a different approach from group fairness. Instead of comparing groups, it requires that similar individuals receive similar predictions.

Fairness Through Awareness

The principle of individual fairness states: "Similar individuals should be treated similarly." The challenge lies in defining what constitutes "similarity" in a way that is both meaningful and doesn't encode bias.

# Example: Individual Fairness Check
from sklearn.metrics.pairwise import cosine_similarity

def check_individual_fairness(X, y_pred, threshold=0.9):
    """
    Check if similar individuals receive similar predictions
    """
    # Calculate pairwise similarity
    similarities = cosine_similarity(X)

    fairness_violations = 0
    total_pairs = 0

    for i in range(len(X)):
        for j in range(i + 1, len(X)):
            if similarities[i, j] > threshold:
                total_pairs += 1
                if y_pred[i] != y_pred[j]:
                    fairness_violations += 1

    violation_rate = fairness_violations / total_pairs if total_pairs > 0 else 0
    print(f"Similar pairs: {total_pairs}")
    print(f"Fairness violations: {fairness_violations}")
    print(f"Violation rate: {violation_rate:.3f}")

    return violation_rate

Disparate Impact Analysis

Disparate impact (also known as adverse impact) is a legal concept used to assess discrimination. The "80% rule" or "four-fifths rule" is commonly used as a threshold.

Disparate Impact Ratio = (Selection Rate for Protected Group) / (Selection Rate for Reference Group)

A ratio below 0.8 (80%) is often considered evidence of disparate impact.

# Disparate Impact Calculation
def disparate_impact(y_pred, protected_attribute, reference_group):
    """
    Calculate disparate impact ratio
    Reference group is typically the majority or advantaged group
    """
    groups = np.unique(protected_attribute)
    selection_rates = {}

    for group in groups:
        mask = protected_attribute == group
        selection_rate = np.mean(y_pred[mask])
        selection_rates[group] = selection_rate

    reference_rate = selection_rates[reference_group]

    print("Selection Rates:")
    for group, rate in selection_rates.items():
        di_ratio = rate / reference_rate if reference_rate > 0 else 0
        status = "PASS" if di_ratio >= 0.8 else "FAIL"

        print(f"  {group}: {rate:.3f} (DI Ratio: {di_ratio:.3f}) [{status}]")

    return selection_rates

# Example
y_pred = np.array([1, 1, 1, 1, 0, 1, 0, 0, 1, 0])
protected = np.array(['majority', 'majority', 'majority', 'majority', 'majority',
                      'protected', 'protected', 'protected', 'protected', 'protected'])

rates = disparate_impact(y_pred, protected, 'majority')

Calibration and Score-Based Metrics

For models that output probability scores rather than hard classifications, calibration is an important fairness consideration.

Calibration Across Groups

A model is calibrated if predictions reflect true probabilities. Group calibration requires this to hold separately for each demographic group.

# Check Calibration by Group
def calibration_by_group(y_true, y_score, protected_attribute, n_bins=10):
    """
    Assess calibration separately for each group
    """
    from sklearn.calibration import calibration_curve
    import matplotlib.pyplot as plt

    groups = np.unique(protected_attribute)

    for group in groups:
        mask = protected_attribute == group

        prob_true, prob_pred = calibration_curve(
            y_true[mask],
            y_score[mask],
            n_bins=n_bins,
            strategy='uniform'
        )

        print(f"\nGroup {group} Calibration:")
        for i, (true_p, pred_p) in enumerate(zip(prob_true, prob_pred)):
            print(f"  Bin {i+1}: Predicted={pred_p:.3f}, Actual={true_p:.3f}")

    return True

Choosing the Right Metric

Selecting appropriate fairness metrics depends on your application domain, legal requirements, and ethical considerations:

Application Recommended Metrics Rationale
Criminal Justice Equalized Odds, Equal Opportunity Both false positives and false negatives have serious consequences
Lending/Credit Disparate Impact, Demographic Parity Legal compliance (ECOA), equal access requirements
Hiring Disparate Impact, Equal Opportunity Legal compliance (EEOC), focus on qualified candidates
Healthcare Equal Opportunity, Calibration Minimize missed diagnoses, reliable risk scores
Content Recommendation Demographic Parity, Individual Fairness Equal exposure, personalized treatment

Comprehensive Fairness Assessment

In practice, you should evaluate multiple fairness metrics to get a complete picture:

# Comprehensive Fairness Report
def fairness_report(y_true, y_pred, protected_attribute):
    """
    Generate comprehensive fairness assessment
    """
    print("=" * 60)
    print("FAIRNESS ASSESSMENT REPORT")
    print("=" * 60)

    # 1. Demographic Parity
    print("\n1. DEMOGRAPHIC PARITY")
    print("-" * 60)
    dp_ratio, _ = demographic_parity(y_pred, protected_attribute)

    # 2. Equalized Odds
    print("\n2. EQUALIZED ODDS")
    print("-" * 60)
    eo_results = equalized_odds(y_true, y_pred, protected_attribute)

    # 3. Equal Opportunity
    print("\n3. EQUAL OPPORTUNITY")
    print("-" * 60)
    eop_results = equal_opportunity(y_true, y_pred, protected_attribute)

    # 4. Predictive Parity
    print("\n4. PREDICTIVE PARITY")
    print("-" * 60)
    pp_results = predictive_parity(y_true, y_pred, protected_attribute)

    # Summary
    print("\n" + "=" * 60)
    print("SUMMARY")
    print("=" * 60)
    print("Review all metrics above to make informed fairness assessment.")
    print("Remember: Different metrics may conflict. Choose based on your")
    print("specific use case and stakeholder values.")
    print("\n弘益人間 - Ensure your AI benefits all humanity equitably")
    print("=" * 60)

    return {
        'demographic_parity': dp_ratio,
        'equalized_odds': eo_results,
        'equal_opportunity': eop_results,
        'predictive_parity': pp_results
    }

Chapter Summary

Review Questions

  1. What is demographic parity and when is it an appropriate fairness metric? What are its limitations?
  2. Explain the difference between equalized odds and equal opportunity. When would you choose one over the other?
  3. Why is it mathematically impossible to satisfy all fairness criteria simultaneously? Provide an example.
  4. What is the 80% rule in disparate impact analysis? What does it indicate when a system fails this test?
  5. How does individual fairness differ from group fairness? What are the challenges in implementing individual fairness?
  6. A lending model has equal precision across demographic groups but different false positive rates. Which fairness criteria does it satisfy and which does it violate?
  7. Describe a scenario where optimizing for demographic parity could lead to unfair individual-level outcomes.
  8. Why is calibration important for fairness? How can a model be well-calibrated overall but poorly calibrated for specific groups?
  9. Design a fairness evaluation strategy for a healthcare diagnostic AI. Which metrics would you prioritize and why?
  10. How does the 弘益人間 philosophy guide the selection and application of fairness metrics?