import numpy as np, pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
plt.rcParams.update({"figure.dpi":110,"font.size":11,"axes.spines.top":False,"axes.spines.right":False,
"axes.grid":True,"grid.alpha":0.22,"axes.titleweight":"bold","axes.titlesize":12.5,"axes.titlelocation":"left"})
ROSE, BL, GR, RD, MUT = "#be123c", "#2563eb", "#16a34a", "#dc2626", "#94a3b8"
BASE = "https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
fn = "data-ethics--lending.xlsx"
try: df = pd.read_excel("../../data/" + fn, sheet_name="applicants")
except FileNotFoundError: df = pd.read_excel(BASE + fn, sheet_name="applicants")
# NOTE: the protected attribute 'group' is deliberately NOT a model input ("fairness through unawareness").
FEAT = ["income_k", "debt_to_income", "credit_history_years", "prior_defaults"]
Xtr, Xte, ytr, yte, gtr, gte = train_test_split(df[FEAT], df.repaid, df.group, test_size=0.4, random_state=1, stratify=df.group)
clf = LogisticRegression(max_iter=1000).fit(Xtr, ytr)
te = pd.DataFrame({"group": gte.values, "repaid": yte.values, "p": clf.predict_proba(Xte)[:,1]})
te["approved"] = (te.p >= 0.5).astype(int)
A, Bg = te[te.group=="A"], te[te.group=="B"]
print("test set:", len(te), "| group A", len(A), "| group B", len(Bg))
test set: 1600 | group A 1089 | group B 511
Challenge 1 · Disparate impact and the 80% rule¶
Compute the ratio of group B's approval rate to group A's, and state whether it passes.
di = Bg.approved.mean() / A.approved.mean()
print(f"disparate impact ratio B/A = {di:.2f}")
print("passes the 80% rule:" , di >= 0.80, "->", "fair by this test" if di>=0.8 else "flagged as adverse impact")
disparate impact ratio B/A = 0.37 passes the 80% rule: False -> flagged as adverse impact
Challenge 2 · The equal-opportunity gap¶
Among applicants who would actually repay, compare the approval (true positive) rates.
tpr_A = A[A.repaid==1].approved.mean(); tpr_B = Bg[Bg.repaid==1].approved.mean()
print(f"true positive rate A {tpr_A:.1%} B {tpr_B:.1%} equal-opportunity gap {tpr_A-tpr_B:.1%}")
true positive rate A 81.1% B 41.1% equal-opportunity gap 40.0%
Challenge 3 · Check calibration by group¶
Compare each group's mean predicted probability to its actual repayment rate, and explain what a small gap means.
cal = te.groupby("group").agg(predicted=("p","mean"), actual=("repaid","mean")).round(3)
cal["gap"] = (cal.predicted - cal.actual).round(3)
print(cal.to_string())
print("\nSmall gaps mean the scores are honest for both groups: a 0.4 score really does mean about 40% repay.")
print("Calibration can hold even when selection and error rates do not, which is the core tension of fair ML.")
predicted actual gap group A 0.588 0.583 0.005 B 0.411 0.395 0.016 Small gaps mean the scores are honest for both groups: a 0.4 score really does mean about 40% repay. Calibration can hold even when selection and error rates do not, which is the core tension of fair ML.
Challenge 4 · Force parity, measure the cost¶
Lower group B's threshold to match A's approval rate, and report the change in accuracy.
sel_A = A.approved.mean()
thr = np.quantile(Bg.p, 1 - sel_A)
fair = (Bg.p >= thr).astype(int)
print(f"group-B threshold {thr:.2f} | approval {Bg.approved.mean():.1%} -> {fair.mean():.1%}")
print(f"group-B accuracy {(Bg.approved==Bg.repaid).mean():.1%} -> {(fair==Bg.repaid).mean():.1%} (cost of parity)")
group-B threshold 0.32 | approval 27.0% -> 73.4% group-B accuracy 65.9% -> 55.2% (cost of parity)
Challenge 5 · Which fairness definition fits? (a judgment)¶
This is a reasoning task; here is a defensible answer.
For a lending decision, the harm that matters most is denying credit to someone who would have repaid, a lost opportunity that falls hardest on the already-disadvantaged group. That points to equal opportunity (equal true positive rates) as the primary target: everyone who would repay should have the same chance of approval, regardless of group. Demographic parity alone could be gamed by approving unqualified applicants, and pure calibration, though honest, permits the very gap we are worried about. So: aim for equal opportunity, disclose the choice and its cost, and keep a human in the loop for appeals. The right answer depends on the domain, and stating it openly is part of doing this ethically.
Wrap-up¶
Four measurements and one judgment: the disparate impact ratio, the equal-opportunity gap, a calibration check, the accuracy cost of parity, and a reasoned choice of which definition to honor. The numbers are objective; the choice among them is a values decision that should be made and documented by people, not hidden inside a model.