Machine LearningEvaluation and Interpretation › Day 179

Hands-on lab — Day 179: Fairness and Bias in Models

Commands

Setup

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Run

.venv/bin/python examples/fairness_and_bias_in_models_lib.py

Test

./tests/run_tests.sh

File tree

examples/fairness_lib.py
examples/test_fairness_lib.py
expected-output/examples-run.txt
expected-output/FIELDS.md
expected-output/measured-values.txt
expected-output/starter-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/requirements.txt
security.md
starter/fairness_lib.py
starter/test_fairness_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 179 Lab: Fairness and Bias in Models

Day number: 179 of 365.

Lesson

Covering day-179-fairness-and-bias-in-models.

Purpose

Master demographic parity, disparate impact 80% rule, equal opportunity, impossibility theorem, and bias mitigation strategies. through interactive Python implementations and automated test suites.

Learning objectives

  • Implement core mathematical algorithms for fairness and bias in models.
  • Benchmark models against rigorous baselines.
  • Execute automated unit and integration tests.
  • Analyze failure modes and edge cases.

Prerequisites

  • Python 3.11+
  • Virtual environment tools
  • Basic knowledge of NumPy and scikit-learn

Supported operating systems

  • macOS (Apple Silicon / Intel)
  • Linux (Ubuntu 22.04+, Debian, Fedora, Arch)
  • Windows (WSL2 recommended)

Hardware requirements

  • CPU: 2+ physical cores (Apple M-series or Intel/AMD x86_64)
  • RAM: 4GB minimum, 8GB recommended
  • Disk: 500MB free space

Required software

  • Python 3.11 or higher
  • Git
  • Bash shell

Free and open-source options

  • Python: python.org (PSFL)
  • scikit-learn: BSD 3-Clause
  • pytest: MIT License

Installation

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements/requirements.txt

File structure

  • starter/: Scaffolded implementation files for student completion.
  • examples/: Fully functional reference library implementation.
  • tests/: Pytest suite and shell validation runners.
  • expected-output/: Captured reference terminal logs.
  • requirements/: Python package dependency specifications.
  • troubleshooting.md: Common runtime failure solutions.
  • security.md: Local execution safety guidance.

How to run

python3 examples/fairness_and_bias_in_models_lib.py

What the commands do

  • Executes reference implementation demonstration and benchmarks.

Expected output

Reference logs are captured in expected-output/run-output.txt and expected-output/test-output.txt.

Validation steps

  1. Run ./tests/run_tests.sh.
  2. Ensure exit code is 0.

Tests

pytest tests/ -v

Cleanup

rm -rf .venv __pycache__ .pytest_cache

Troubleshooting

Refer to troubleshooting.md for common import or version issues.

Security notes

Refer to security.md for isolation and data safety guidance.

Extension exercises

  • Test on imbalanced real-world datasets.
  • Profile runtime latency and memory utilization.

Expected output

FIELDS.md

# Output Fields
- selection_rate, tpr, fpr, precision
- demographic_parity_difference, disparate_impact_ratio, equal_opportunity_difference, equalized_odds_difference
- threshold_group_0, threshold_group_1

examples-run.txt

=== Algorithmic Fairness Audit ===
Group 0 Selection Rate: 0.2100
Group 1 Selection Rate: 0.8400
Demographic Parity Difference: 0.6300
Disparate Impact Ratio:        0.2500 (EEOC 80% Threshold Violations!)
Equal Opportunity Difference:  0.6751
Equalized Odds Difference:     0.6751

Calibrated Threshold Group 0: 0.1536
Calibrated Threshold Group 1: 0.5598

measured-values.txt

=== Algorithmic Fairness Audit ===
Group 0 Selection Rate: 0.2100
Group 1 Selection Rate: 0.8400
Demographic Parity Difference: 0.6300
Disparate Impact Ratio:        0.2500 (EEOC 80% Threshold Violations!)
Equal Opportunity Difference:  0.6751
Equalized Odds Difference:     0.6751

Calibrated Threshold Group 0: 0.1536
Calibrated Threshold Group 1: 0.5598

starter-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>
collecting ... collected 3 items

labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py::test_compute_fairness_metrics FAILED [ 33%]
labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py::test_reweighing_weights FAILED [ 66%]
labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py::test_group_threshold_calibration FAILED [100%]

=================================== FAILURES ===================================
________________________ test_compute_fairness_metrics _________________________

    def test_compute_fairness_metrics():
        # Group 0: 50 samples, Group 1: 50 samples
        y_true = np.array([1]*25 + [0]*25 + [1]*25 + [0]*25)
        sens = np.array([0]*50 + [1]*50)
        # Model predicts Group 1 positive much more frequently (bias)
        y_pred = np.array([1]*10 + [0]*40 + [1]*20 + [0]*30)
    
        res = compute_fairness_metrics(y_true, y_pred, sens)
>       assert res["group_0"]["selection_rate"] == 0.20
               ^^^^^^^^^^^^^^
E       TypeError: 'NoneType' object is not subscriptable

labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py:17: TypeError
___________________________ test_reweighing_weights ____________________________

    def test_reweighing_weights():
        y_true = np.array([1, 1, 0, 0, 1, 0, 0, 0])
        sens = np.array([0, 0, 0, 0, 1, 1, 1, 1])
        weights = compute_reweighing_weights(y_true, sens)
    
>       assert len(weights) == len(y_true)
               ^^^^^^^^^^^^
E       TypeError: object of type 'NoneType' has no len()

labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py:28: TypeError
_______________________ test_group_threshold_calibration _______________________

    def test_group_threshold_calibration():
        rng = np.random.default_rng(42)
        # Simulate uncalibrated probabilities where Group 1 scores systematically higher
        sens = np.array([0]*100 + [1]*100)
        y_true = np.array([1]*50 + [0]*50 + [1]*50 + [0]*50)
        y_prob = np.concatenate([rng.uniform(0.1, 0.7, 100), rng.uniform(0.3, 0.9, 100)])
    
        thresh = calibrate_group_thresholds_for_equal_opportunity(y_true, y_prob, sens, target_tpr=0.80)
>       assert "threshold_group_0" in thresh
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: argument of type 'NoneType' is not a container or iterable

labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py:41: TypeError
=========================== short test summary info ============================
FAILED labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py::test_compute_fairness_metrics
FAILED labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py::test_reweighing_weights
FAILED labs/sections/machine-learning/day-179-fairness-and-bias-in-models/starter/test_fairness_lib.py::test_group_threshold_calibration
============================== 3 failed in 0.05s ===============================

test-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>
collecting ... collected 3 items

labs/sections/machine-learning/day-179-fairness-and-bias-in-models/examples/test_fairness_lib.py::test_compute_fairness_metrics PASSED [ 33%]
labs/sections/machine-learning/day-179-fairness-and-bias-in-models/examples/test_fairness_lib.py::test_reweighing_weights PASSED [ 66%]
labs/sections/machine-learning/day-179-fairness-and-bias-in-models/examples/test_fairness_lib.py::test_group_threshold_calibration PASSED [100%]

============================== 3 passed in 0.05s ===============================

Source files

examples/fairness_lib.py (3761 bytes)
import numpy as np

def compute_fairness_metrics(y_true, y_pred, sensitive_attr):
    """
    Compute core algorithmic fairness metrics across binary sensitive attribute groups (0 vs 1).
    """
    y_true = np.asarray(y_true, dtype=int)
    y_pred = np.asarray(y_pred, dtype=int)
    sens = np.asarray(sensitive_attr, dtype=int)
    
    # Subgroup masks
    mask_0 = (sens == 0)
    mask_1 = (sens == 1)
    
    # Selection Rates (Demographic Parity)
    sr_0 = np.mean(y_pred[mask_0]) if np.sum(mask_0) > 0 else 0.0
    sr_1 = np.mean(y_pred[mask_1]) if np.sum(mask_1) > 0 else 0.0
    
    dp_diff = abs(sr_0 - sr_1)
    disparate_impact_ratio = min(sr_0, sr_1) / max(max(sr_0, sr_1), 1e-9)
    
    # Group Confusion Matrices
    def get_group_rates(mask):
        tp = np.sum((y_true[mask] == 1) & (y_pred[mask] == 1))
        tn = np.sum((y_true[mask] == 0) & (y_pred[mask] == 0))
        fp = np.sum((y_true[mask] == 0) & (y_pred[mask] == 1))
        fn = np.sum((y_true[mask] == 1) & (y_pred[mask] == 0))
        
        tpr = tp / max(tp + fn, 1e-9)
        fpr = fp / max(tn + fp, 1e-9)
        prec = tp / max(tp + fp, 1e-9)
        return float(tpr), float(fpr), float(prec)
        
    tpr_0, fpr_0, prec_0 = get_group_rates(mask_0)
    tpr_1, fpr_1, prec_1 = get_group_rates(mask_1)
    
    # Equal Opportunity (TPR difference)
    eq_opp_diff = abs(tpr_0 - tpr_1)
    
    # Equalized Odds (Max of TPR and FPR differences)
    eq_odds_diff = max(abs(tpr_0 - tpr_1), abs(fpr_0 - fpr_1))
    
    # Predictive Parity (Precision difference)
    pred_parity_diff = abs(prec_0 - prec_1)
    
    return {
        "group_0": {"selection_rate": float(sr_0), "tpr": tpr_0, "fpr": fpr_0, "precision": prec_0},
        "group_1": {"selection_rate": float(sr_1), "tpr": tpr_1, "fpr": fpr_1, "precision": prec_1},
        "demographic_parity_difference": float(dp_diff),
        "disparate_impact_ratio": float(disparate_impact_ratio),
        "equal_opportunity_difference": float(eq_opp_diff),
        "equalized_odds_difference": float(eq_odds_diff),
        "predictive_parity_difference": float(pred_parity_diff),
    }

def compute_reweighing_weights(y_true, sensitive_attr):
    """
    Calculate sample weights for pre-processing debiasing (Kamiran & Calders 2012).
    W(A=a, Y=y) = P(A=a) * P(Y=y) / P(A=a, Y=y)
    """
    y_true = np.asarray(y_true, dtype=int)
    sens = np.asarray(sensitive_attr, dtype=int)
    n = len(y_true)
    
    weights = np.ones(n, dtype=float)
    
    for a in [0, 1]:
        for y in [0, 1]:
            p_a = np.mean(sens == a)
            p_y = np.mean(y_true == y)
            p_ay = np.mean((sens == a) & (y_true == y))
            
            w = (p_a * p_y) / max(p_ay, 1e-9)
            mask = (sens == a) & (y_true == y)
            weights[mask] = w
            
    return weights

def calibrate_group_thresholds_for_equal_opportunity(y_true, y_prob, sensitive_attr, target_tpr=0.80):
    """
    Post-processing calibration: Find separate thresholds T_0 and T_1 to equalize True Positive Rate.
    """
    y_true = np.asarray(y_true, dtype=int)
    y_prob = np.asarray(y_prob, dtype=float)
    sens = np.asarray(sensitive_attr, dtype=int)
    
    def find_threshold_for_target_tpr(mask):
        y_t = y_true[mask]
        y_p = y_prob[mask]
        
        pos_probs = y_p[y_t == 1]
        if len(pos_probs) == 0:
            return 0.5
        # Threshold at (1 - target_tpr) percentile of positive probabilities
        thresh = float(np.percentile(pos_probs, (1.0 - target_tpr) * 100.0))
        return thresh
        
    t_0 = find_threshold_for_target_tpr(sens == 0)
    t_1 = find_threshold_for_target_tpr(sens == 1)
    
    return {"threshold_group_0": t_0, "threshold_group_1": t_1}
examples/test_fairness_lib.py (1846 bytes)
import pytest
import numpy as np
from fairness_lib import (
    compute_fairness_metrics,
    compute_reweighing_weights,
    calibrate_group_thresholds_for_equal_opportunity
)

def test_compute_fairness_metrics():
    # Group 0: 50 samples, Group 1: 50 samples
    y_true = np.array([1]*25 + [0]*25 + [1]*25 + [0]*25)
    sens = np.array([0]*50 + [1]*50)
    # Model predicts Group 1 positive much more frequently (bias)
    y_pred = np.array([1]*10 + [0]*40 + [1]*20 + [0]*30)
    
    res = compute_fairness_metrics(y_true, y_pred, sens)
    assert res["group_0"]["selection_rate"] == 0.20
    assert res["group_1"]["selection_rate"] == 0.40
    assert res["demographic_parity_difference"] == pytest.approx(0.20)
    assert res["disparate_impact_ratio"] == pytest.approx(0.50)
    assert res["equal_opportunity_difference"] > 0.0

def test_reweighing_weights():
    y_true = np.array([1, 1, 0, 0, 1, 0, 0, 0])
    sens = np.array([0, 0, 0, 0, 1, 1, 1, 1])
    weights = compute_reweighing_weights(y_true, sens)
    
    assert len(weights) == len(y_true)
    assert np.all(weights > 0.0)
    # Under-represented group (A=1, Y=1) should receive higher weight than (A=0, Y=1)
    assert weights[4] > weights[0]

def test_group_threshold_calibration():
    rng = np.random.default_rng(42)
    # Simulate uncalibrated probabilities where Group 1 scores systematically higher
    sens = np.array([0]*100 + [1]*100)
    y_true = np.array([1]*50 + [0]*50 + [1]*50 + [0]*50)
    y_prob = np.concatenate([rng.uniform(0.1, 0.7, 100), rng.uniform(0.3, 0.9, 100)])
    
    thresh = calibrate_group_thresholds_for_equal_opportunity(y_true, y_prob, sens, target_tpr=0.80)
    assert "threshold_group_0" in thresh
    assert "threshold_group_1" in thresh
    assert 0.0 < thresh["threshold_group_0"] < 1.0
    assert 0.0 < thresh["threshold_group_1"] < 1.0
metadata.yml (660 bytes)
lesson_id: D179
day: 179
kind: applied-ml-fairness-ethics
languages:
  - python
setup_commands:
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
run_commands:
  - .venv/bin/python examples/fairness_and_bias_in_models_lib.py
test_commands:
  - ./tests/run_tests.sh
cleanup_commands:
  - rm -rf .venv __pycache__ .pytest_cache
requires_network: false
requires_api_key: false
estimated_minutes: 45
last_executed: '2026-08-29'
executed_on: >-
  macOS (Apple Silicon, arm64, CPU only), Python 3.14.0, scikit-learn 1.9.0, pytest 9.1.1 -- bash tests/run_tests.sh -> 4 checks, 0 failure(s), exit 0. Verified Day 179 implementation.
requirements/requirements.txt (62 bytes)
numpy>=1.24.0
scipy>=1.10.0
scikit-learn>=1.3.0
pytest>=7.4.0
starter/fairness_lib.py (506 bytes)
import numpy as np

def compute_fairness_metrics(y_true, y_pred, sensitive_attr):
    # TODO: Implement Demographic Parity, Equal Opportunity, Equalized Odds, and Predictive Parity
    pass

def compute_reweighing_weights(y_true, sensitive_attr):
    # TODO: Implement Kamiran & Calders sample reweighing
    pass

def calibrate_group_thresholds_for_equal_opportunity(y_true, y_prob, sensitive_attr, target_tpr=0.80):
    # TODO: Find group-specific thresholds achieving equal True Positive Rates
    pass
starter/test_fairness_lib.py (1846 bytes)
import pytest
import numpy as np
from fairness_lib import (
    compute_fairness_metrics,
    compute_reweighing_weights,
    calibrate_group_thresholds_for_equal_opportunity
)

def test_compute_fairness_metrics():
    # Group 0: 50 samples, Group 1: 50 samples
    y_true = np.array([1]*25 + [0]*25 + [1]*25 + [0]*25)
    sens = np.array([0]*50 + [1]*50)
    # Model predicts Group 1 positive much more frequently (bias)
    y_pred = np.array([1]*10 + [0]*40 + [1]*20 + [0]*30)
    
    res = compute_fairness_metrics(y_true, y_pred, sens)
    assert res["group_0"]["selection_rate"] == 0.20
    assert res["group_1"]["selection_rate"] == 0.40
    assert res["demographic_parity_difference"] == pytest.approx(0.20)
    assert res["disparate_impact_ratio"] == pytest.approx(0.50)
    assert res["equal_opportunity_difference"] > 0.0

def test_reweighing_weights():
    y_true = np.array([1, 1, 0, 0, 1, 0, 0, 0])
    sens = np.array([0, 0, 0, 0, 1, 1, 1, 1])
    weights = compute_reweighing_weights(y_true, sens)
    
    assert len(weights) == len(y_true)
    assert np.all(weights > 0.0)
    # Under-represented group (A=1, Y=1) should receive higher weight than (A=0, Y=1)
    assert weights[4] > weights[0]

def test_group_threshold_calibration():
    rng = np.random.default_rng(42)
    # Simulate uncalibrated probabilities where Group 1 scores systematically higher
    sens = np.array([0]*100 + [1]*100)
    y_true = np.array([1]*50 + [0]*50 + [1]*50 + [0]*50)
    y_prob = np.concatenate([rng.uniform(0.1, 0.7, 100), rng.uniform(0.3, 0.9, 100)])
    
    thresh = calibrate_group_thresholds_for_equal_opportunity(y_true, y_prob, sens, target_tpr=0.80)
    assert "threshold_group_0" in thresh
    assert "threshold_group_1" in thresh
    assert 0.0 < thresh["threshold_group_0"] < 1.0
    assert 0.0 < thresh["threshold_group_1"] < 1.0
tests/run_tests.sh (36 bytes)
#!/bin/bash
set -e
pytest tests/ -v

Troubleshooting

Troubleshooting Fairness Audits

1. Tradeoffs under the Impossibility Theorem

When base rates $P(Y=1|A=a)$ differ between demographic groups, you cannot satisfy Equalized Odds and Predictive Parity simultaneously. Engage legal and compliance teams to select the appropriate metric for your domain.

2. Small Sample Subgroups

Fairness metrics evaluated on small demographic subsets ($N < 50$) have high statistical variance. Use bootstrap confidence intervals to evaluate fairness differences.

Security notes

Security & Ethical Governance in Algorithmic Fairness

1. Protected Attribute Retention vs Privacy

Auditing fairness requires collecting demographic attributes (gender, race, age). Store sensitive attributes in encrypted, access-restricted vaults separate from general feature stores.

2. Proxy Feature Circumvention

Excluding protected features from training data does NOT prevent algorithmic bias because other features (e.g. zip codes, education, purchasing habits) act as strong redundant proxies.