#!/usr/bin/env python3
"""Money laundering for bias: dropping the protected attribute does not remove it.

DDIA 2e Ch. 14 (Doing the Right Thing), section "Bias and Discrimination":
    "in racially segregated neighborhoods, a person's postal code or even their
     IP address is a strong predictor of race ... machine learning is like money
     laundering for bias."

The obvious fix for a discriminatory model is "fairness through unawareness":
just don't feed the model the protected attribute (race), and its decisions
become fair. This script shows why that fails.

We build a SEEDED SYNTHETIC loan dataset:
  - A protected binary attribute A (A=1 is the historically disadvantaged group).
  - A neutral-looking PROXY feature ("neighborhood", think postal code) that is
    strongly correlated with A -- segregation leaks the trait into geography.
  - Legitimate creditworthiness features (income, credit score, employment
    years) drawn INDEPENDENTLY of A, so equally-qualified applicants differ only
    by group membership.
  - A historically BIASED label: approval depends on the legitimate features
    MINUS a penalty applied to group A=1. The bias lives entirely in A.

Then we train three classifiers on the biased labels and audit each with the
disparate-impact ratio = selection_rate(A=1) / selection_rate(A=0) (the
four-fifths / 0.8 rule: below 0.8 is legally a red flag):
  M1: WITH A                     -- uses the protected column directly.
  M2: WITHOUT A                  -- "fairness through unawareness".
  M3: WITHOUT A AND the proxy    -- removes the trait AND its geographic shadow.

Finally we train a recovery model M4 that predicts A from the non-A features and
report its AUC -- if that is well above 0.5, the proxy really does carry the
trait, which is exactly why M2 stays biased.

Run it:
    uv run --python 3.12 --with scikit-learn --with numpy python3 proxy_bias.py
(Seeded: numpy rng 42, every model random_state=42 -> metrics reproduce exactly.)
"""
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

N = 20_000
SEED = 42


def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))


def build_dataset(rng):
    # Protected attribute: A=1 is the historically disadvantaged group (~half).
    A = rng.binomial(1, 0.5, N)

    # Proxy: a "neighborhood" score (postal code). Segregation means the two
    # groups live in different places -- so the proxy strongly encodes A, even
    # though nobody labelled it "race". Legitimate lenders DO use geography.
    neighborhood = rng.normal(loc=3.0 * A, scale=1.2, size=N)

    # Legitimate creditworthiness features -- drawn INDEPENDENTLY of A, so the
    # two groups are equally qualified on the merits. Any gap in approvals is
    # therefore pure bias, not a real difference in risk.
    income = rng.normal(50, 15, N)
    credit_score = rng.normal(680, 60, N)
    employment_years = rng.exponential(5, N)

    # True creditworthiness: a function of the legitimate features only.
    merit = (0.05 * (income - 50)
             + 0.02 * (credit_score - 680)
             + 0.15 * (employment_years - 5))

    # Historically biased label: approve on merit, MINUS a penalty for A=1.
    # The bias is injected through A alone -- the proxy is only *correlated*
    # with A, it is not in this formula.
    BIAS = 2.5
    logit = 1.25 + merit - BIAS * A
    approved = rng.binomial(1, sigmoid(logit))

    X = np.column_stack([income, credit_score, employment_years, neighborhood, A])
    return X, approved, A


def disparate_impact(pred, A):
    """selection_rate(disadvantaged A=1) / selection_rate(advantaged A=0)."""
    rate1 = pred[A == 1].mean()
    rate0 = pred[A == 0].mean()
    return rate1, rate0, rate1 / rate0


def train_and_audit(Xtr, ytr, Xte, Ate):
    clf = RandomForestClassifier(n_estimators=200, random_state=SEED, n_jobs=1)
    clf.fit(Xtr, ytr)
    pred = clf.predict(Xte)
    return disparate_impact(pred, Ate)


def main():
    rng = np.random.default_rng(SEED)
    X, y, A = build_dataset(rng)

    # Column layout: 0 income, 1 credit, 2 employment, 3 neighborhood(proxy), 4 A.
    IDX_PROXY, IDX_A = 3, 4

    Xtr, Xte, ytr, yte, Atr, Ate = train_test_split(
        X, y, A, test_size=0.3, random_state=SEED, stratify=A)

    print("=" * 68)
    print("SYNTHETIC LOAN DATASET (seeded, deterministic)")
    print("=" * 68)
    print(f"{N:,} applicants; A=1 is the historically disadvantaged group.")
    print("Legitimate features (income, credit, employment) are drawn")
    print("INDEPENDENTLY of A -- the two groups are equally qualified.")
    print("The label carries historical bias: approval = merit - penalty*(A==1).\n")

    r1, r0, ratio = disparate_impact(y, A)
    print("Disparate impact in the HISTORICAL LABELS themselves:")
    print(f"    approval rate  A=1 (disadvantaged) = {r1:6.1%}")
    print(f"    approval rate  A=0 (advantaged)    = {r0:6.1%}")
    print(f"    disparate-impact ratio             = {ratio:5.2f}   "
          f"(four-fifths rule: below 0.80 is a red flag)\n")

    # ---- M1: WITH the protected attribute ----
    r1, r0, ratio_with = train_and_audit(Xtr, ytr, Xte, Ate)
    print("=" * 68)
    print("M1  -- train WITH the protected attribute A")
    print("=" * 68)
    print(f"    selection rate  A=1 = {r1:6.1%}    A=0 = {r0:6.1%}")
    print(f"    disparate-impact ratio = {ratio_with:5.2f}\n")

    # ---- M2: WITHOUT A (fairness through unawareness) ----
    keep = [c for c in range(X.shape[1]) if c != IDX_A]
    r1, r0, ratio_without = train_and_audit(Xtr[:, keep], ytr, Xte[:, keep], Ate)
    print("=" * 68)
    print("M2  -- train WITHOUT A  ('fairness through unawareness': drop race)")
    print("=" * 68)
    print(f"    selection rate  A=1 = {r1:6.1%}    A=0 = {r0:6.1%}")
    print(f"    disparate-impact ratio = {ratio_without:5.2f}   "
          f"<-- barely moved; still unfair\n")

    # ---- M3: WITHOUT A AND the proxy ----
    keep2 = [c for c in range(X.shape[1]) if c not in (IDX_A, IDX_PROXY)]
    r1, r0, ratio_no_proxy = train_and_audit(Xtr[:, keep2], ytr, Xte[:, keep2], Ate)
    print("=" * 68)
    print("M3  -- train WITHOUT A AND WITHOUT the proxy (neighborhood)")
    print("=" * 68)
    print(f"    selection rate  A=1 = {r1:6.1%}    A=0 = {r0:6.1%}")
    print(f"    disparate-impact ratio = {ratio_no_proxy:5.2f}   "
          f"<-- now above 0.80\n")

    # ---- M4: can we reconstruct A from the non-A features? ----
    rec = LogisticRegression(max_iter=1000, random_state=SEED)
    rec.fit(Xtr[:, keep], Atr)          # predict A from everything except A
    auc_all = roc_auc_score(Ate, rec.predict_proba(Xte[:, keep])[:, 1])

    rec_proxy = LogisticRegression(max_iter=1000, random_state=SEED)
    rec_proxy.fit(Xtr[:, [IDX_PROXY]], Atr)   # predict A from the proxy alone
    auc_proxy = roc_auc_score(Ate, rec_proxy.predict_proba(Xte[:, [IDX_PROXY]])[:, 1])

    print("=" * 68)
    print("MECHANISM CHECK -- recover A from the non-protected features")
    print("=" * 68)
    print(f"    predict A from ALL non-A features : AUC = {auc_all:5.2f}")
    print(f"    predict A from the PROXY alone    : AUC = {auc_proxy:5.2f}")
    print("    (0.50 = coin flip. Well above 0.50 => the trait is still in there.)\n")

    print("=" * 68)
    print("SUMMARY")
    print("=" * 68)
    print(f"    disparate-impact ratio  WITH A                 = {ratio_with:5.2f}")
    print(f"    disparate-impact ratio  WITHOUT A              = {ratio_without:5.2f}")
    print(f"    disparate-impact ratio  WITHOUT A AND proxy    = {ratio_no_proxy:5.2f}")
    print(f"    A recovered from remaining features   AUC      = {auc_all:5.2f}")


if __name__ == "__main__":
    main()
