AI Fairness
Fairness is a choice of which equal treatment you enforce, because the obvious definitions clash when groups start from different base rates.
Why Does This Exist?
Loan, hiring and court tools decide for thousands of people in one run. A single accuracy number can hide a split: one group gets the benefit far more often, or pays for the errors far more often. You need named definitions before you can argue about which split is acceptable, and before an audit means anything.
This page gives you the definitions, the clash between them, and a ten plus ten example you can recheck by hand. Where the skew comes from lives in algorithmic bias. The person level split lives in individual vs group fairness. The legal reading lives in disparate impact. The audit routine lives in model auditing.
Think of It Like This
Two tryouts, five shirts each
You coach tryouts on two nights, ten kids each night. On Monday only two can truly play. On Tuesday six can. You have five shirts per night, no more.
You hand out all five Monday shirts, so both real players get one. On Tuesday you also hand out five, so five of the six real players get one. Same shirts per night, different coverage of talent: 100 percent on Monday, 83 percent on Tuesday.
Fair by shirts, uneven by talent spotted. Or you cover talent evenly and hand out two shirts Monday and six Tuesday, which looks uneven by shirts. You cannot satisfy both counts at once. That is the whole clash.
Where it stops: kids choose to show up and you can watch them play. A model scores traces of the past, and no shirt count settles which equal treatment your users or the law actually want.
How It Actually Works
Let be the group label, with values 0 and 1. Let be the true outcome, where 1 means the person qualifies. Let be the model decision, where 1 means the model selects the person. Every definition below compares a rate across the two values of .
- Selection rate for group is : the share selected.
- True positive rate for group is : of the qualified, the share selected.
- False positive rate for group is : of the unqualified, the share wrongly selected.
- Base rate for group is : the share who truly qualify.
The main definitions
- Demographic parity asks for equal selection rates: . It says nothing about who was qualified.
- Equal opportunity asks for equal true positive rates: qualified people get selected at the same rate in both groups.
- Equalized odds asks for both: equal true positive rates and equal false positive rates.
- Calibration asks that a score means what it says in each group: for every score and group .
Worked example with ten and ten
Group A has ten people and two true qualifiers. Group B has ten people and six true qualifiers. So the base rates are 0.20 and 0.60. Now force demographic parity at five selections each.
Group A selections: 5 of 10, so the selection rate is . Both qualifiers are inside the five, so the true positive rate is . Group B selections: 5 of 10, selection rate . Five of the six qualifiers are inside, so the true positive rate is .
Equal selection, unequal hit rates, with a gap of . The diagram draws exactly this: ten dots per group, a box round five each, and the mismatch called out beside the true positive row.
Why you cannot have them all
Flip the demand. Force equal opportunity at 100 percent instead: select both qualifiers in A and all six in B. True positive rates match at 1.00, but selection rates split to against . When base rates differ, one equality breaks the other. The general result says you cannot hold equal false positive rates, equal false negative rates and calibration all at once outside trivial cases. So you pick the definition that fits the stakes, write it down, and report all three rates anyway.
Code
Two tiny rates, computed on the worked example. No libraries, so you can paste this anywhere.
def selection_rate(pred: list[int]) -> float: return sum(pred) / len(pred)
def true_positive_rate(y: list[int], pred: list[int]) -> float: hits = sum(1 for actual, got in zip(y, pred) if actual == 1 and got == 1) return hits / sum(y)
y_a: list[int] = [1, 1, 0, 0, 0, 0, 0, 0, 0, 0]p_a: list[int] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0]y_b: list[int] = [1, 1, 1, 1, 1, 1, 0, 0, 0, 0]p_b: list[int] = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0]
print(selection_rate(p_a)) # -> 0.5print(true_positive_rate(y_a, p_a)) # -> 1.0print(selection_rate(p_b)) # -> 0.5print(round(true_positive_rate(y_b, p_b), 3)) # -> 0.833print(round(abs(true_positive_rate(y_a, p_a) - true_positive_rate(y_b, p_b)), 3)) # -> 0.167Selection matches at 0.50 while the true positive gap prints 0.167. That printout is the clash in one number.
Watch Out For
Deleting the group column and calling it done
Symptom: you drop the protected column, retrain, and the next audit shows the same gap. The model rebuilt the group from zip code, name frequency or schedule patterns. Fix: keep the column for measurement, test how well proxies predict it, then add a real constraint or per group threshold and re audit. Dropping a column is not a mitigation.
Approving the model on one number
Symptom: parity passes at 0.50 each while the true positive gap quietly grows, or the reverse. One rate always looks fine in isolation. Fix: report selection rate, true positive rate and false positive rate per group together, name which definition governs this use case before tuning, and block release when the governed one misses.
The Quick Version
- Demographic parity equalizes selection rates. Equal opportunity equalizes true positive rates. Equalized odds asks for both error rates.
- Base rates differ in real data, here 0.20 against 0.60, and that difference forces the clash.
- Our ten plus ten fixture selects five each and lands true positive rates at 100 and 83 percent.
- Forcing equal 100 percent hit rates instead splits selection to 20 against 60 percent.
- Proxy features rebuild a deleted group column, so removal never suffices alone.
- Report all three rates per group and name the governing definition before release.