ProCreations commited on
Commit
0690c42
·
verified ·
1 Parent(s): ed2a4b0

Add the missing Claim 5 page: rerun the COMPAS case study on real ProPublica data; optimal fair thresholds differ by 0.08-0.13 across groups and forcing a single threshold costs 52-341 utility

Browse files
code/compas_exp.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Claim 5: a COMPAS case study shows optimal fair classifiers under sufficiency
2
+ generally require GROUP-SPECIFIC decision thresholds rather than one universal
3
+ threshold.
4
+
5
+ Previously: "the COMPAS data experiment was not rerun."
6
+
7
+ Logic of the test. Under sufficiency the score is calibrated within each group,
8
+ so the utility-optimal *unconstrained* rule is a single threshold shared by all
9
+ groups. Imposing a fairness constraint on top changes that: the constrained
10
+ optimum generally needs different thresholds per group, and forcing one universal
11
+ threshold costs utility. Both are measured.
12
+ """
13
+ import json, numpy as np, pandas as pd
14
+ from sklearn.linear_model import LogisticRegression
15
+ from sklearn.calibration import CalibratedClassifierCV
16
+ from sklearn.model_selection import train_test_split
17
+
18
+ RES = {}
19
+
20
+
21
+ def load():
22
+ df = pd.read_pickle("compas.pkl")
23
+ df = df[df["race"].isin(["African-American", "Caucasian"])].copy()
24
+ X = pd.get_dummies(df[["age", "priors_count", "juv_fel_count",
25
+ "juv_misd_count", "c_charge_degree", "sex"]],
26
+ drop_first=True).astype(float).values
27
+ y = df["two_year_recid"].values.astype(int)
28
+ a = (df["race"] == "African-American").values.astype(int)
29
+ return X, y, a
30
+
31
+
32
+ def calibration_by_group(s, y, a, bins=8):
33
+ out = {}
34
+ edges = np.quantile(s, np.linspace(0, 1, bins+1))
35
+ for g in (0, 1):
36
+ m = a == g; rows = []
37
+ for i in range(bins):
38
+ sel = m & (s >= edges[i]) & (s <= edges[i+1])
39
+ if sel.sum() < 25: continue
40
+ rows.append({"bin": i, "mean_score": round(float(s[sel].mean()), 4),
41
+ "empirical_rate": round(float(y[sel].mean()), 4), "n": int(sel.sum())})
42
+ gap = max(abs(r["mean_score"]-r["empirical_rate"]) for r in rows) if rows else None
43
+ out["group%d" % g] = {"bins": rows, "max_abs_calibration_gap": round(gap, 4)}
44
+ return out
45
+
46
+
47
+ def utility(s, y, a, tA, tC, cost_fp=1.0, benefit_tp=1.0):
48
+ d = np.where(a == 1, s >= tA, s >= tC)
49
+ tp = np.sum(d & (y == 1)); fp = np.sum(d & (y == 0))
50
+ return float(benefit_tp*tp-cost_fp*fp)
51
+
52
+
53
+ def fpr(s, y, a, tA, tC, g):
54
+ m = (a == g) & (y == 0)
55
+ t = tA if g == 1 else tC
56
+ return float(np.mean(s[m] >= t)) if m.sum() else 0.0
57
+
58
+
59
+ def main():
60
+ X, y, a = load()
61
+ Xtr, Xte, ytr, yte, atr, ate = train_test_split(X, y, a, test_size=0.5,
62
+ random_state=0, stratify=y)
63
+ base = LogisticRegression(max_iter=2000)
64
+ clf = CalibratedClassifierCV(base, method="isotonic", cv=5).fit(Xtr, ytr)
65
+ s = clf.predict_proba(Xte)[:, 1]
66
+ cal = calibration_by_group(s, yte, ate)
67
+ print(" sufficiency check — max |score - empirical rate| per group: g0=%.4f g1=%.4f"
68
+ % (cal["group0"]["max_abs_calibration_gap"], cal["group1"]["max_abs_calibration_gap"]), flush=True)
69
+
70
+ grid = np.linspace(0.05, 0.95, 91)
71
+ # unconstrained optimum: single threshold (sufficiency => shared)
72
+ uu = [(utility(s, yte, ate, t, t), t) for t in grid]
73
+ u_un, t_un = max(uu)
74
+ print(" unconstrained optimum: single threshold t=%.3f utility=%.1f" % (t_un, u_un), flush=True)
75
+
76
+ rows = []
77
+ for eps in (0.10, 0.05, 0.02, 0.01):
78
+ # constrained: equal FPR across groups within eps
79
+ best_pair, best_u = None, -1e18
80
+ for tA in grid:
81
+ fA = fpr(s, yte, ate, tA, tA, 1)
82
+ for tC in grid:
83
+ fC = fpr(s, yte, ate, tC, tC, 0)
84
+ if abs(fA-fC) > eps: continue
85
+ u = utility(s, yte, ate, tA, tC)
86
+ if u > best_u: best_u, best_pair = u, (tA, tC)
87
+ # constrained but forced to a single universal threshold
88
+ bu_s, bt_s = -1e18, None
89
+ for t in grid:
90
+ if abs(fpr(s, yte, ate, t, t, 1)-fpr(s, yte, ate, t, t, 0)) > eps: continue
91
+ u = utility(s, yte, ate, t, t)
92
+ if u > bu_s: bu_s, bt_s = u, t
93
+ rows.append({"eps_fpr_gap": eps,
94
+ "group_specific_thresholds": [round(best_pair[0], 3), round(best_pair[1], 3)],
95
+ "threshold_difference": round(abs(best_pair[0]-best_pair[1]), 3),
96
+ "utility_group_specific": best_u,
97
+ "best_single_threshold": (round(bt_s, 3) if bt_s is not None else None),
98
+ "utility_single": (bu_s if bt_s is not None else None),
99
+ "utility_loss_from_single": (round(best_u-bu_s, 2) if bt_s is not None else None)})
100
+ print(" eps=%.2f group-specific t=(%.3f, %.3f) diff=%.3f util=%.1f | best single t=%s util=%s loss=%s"
101
+ % (eps, best_pair[0], best_pair[1], rows[-1]["threshold_difference"], best_u,
102
+ rows[-1]["best_single_threshold"], rows[-1]["utility_single"],
103
+ rows[-1]["utility_loss_from_single"]), flush=True)
104
+ RES["claim5_compas"] = {"n_test": int(len(yte)), "calibration": cal,
105
+ "unconstrained_single_threshold": round(t_un, 3), "unconstrained_utility": u_un,
106
+ "rows": rows,
107
+ "thresholds_differ_in_all": all(r["threshold_difference"] > 1e-9 for r in rows),
108
+ "single_threshold_costs_utility_in_all": all(
109
+ r["utility_loss_from_single"] is None or r["utility_loss_from_single"] > 0 for r in rows)}
110
+ json.dump(RES, open("compas_results.json", "w"), indent=1)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main(); print("DONE")
outputs_compas_results.json ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "claim5_compas": {
3
+ "n_test": 3075,
4
+ "calibration": {
5
+ "group0": {
6
+ "bins": [
7
+ {
8
+ "bin": 0,
9
+ "mean_score": 0.1714,
10
+ "empirical_rate": 0.2202,
11
+ "n": 277
12
+ },
13
+ {
14
+ "bin": 1,
15
+ "mean_score": 0.245,
16
+ "empirical_rate": 0.3192,
17
+ "n": 260
18
+ },
19
+ {
20
+ "bin": 2,
21
+ "mean_score": 0.2944,
22
+ "empirical_rate": 0.375,
23
+ "n": 264
24
+ },
25
+ {
26
+ "bin": 3,
27
+ "mean_score": 0.4043,
28
+ "empirical_rate": 0.3897,
29
+ "n": 213
30
+ },
31
+ {
32
+ "bin": 4,
33
+ "mean_score": 0.4633,
34
+ "empirical_rate": 0.392,
35
+ "n": 199
36
+ },
37
+ {
38
+ "bin": 5,
39
+ "mean_score": 0.5913,
40
+ "empirical_rate": 0.5338,
41
+ "n": 133
42
+ },
43
+ {
44
+ "bin": 6,
45
+ "mean_score": 0.681,
46
+ "empirical_rate": 0.6949,
47
+ "n": 118
48
+ },
49
+ {
50
+ "bin": 7,
51
+ "mean_score": 0.7709,
52
+ "empirical_rate": 0.7308,
53
+ "n": 78
54
+ }
55
+ ],
56
+ "max_abs_calibration_gap": 0.0806
57
+ },
58
+ "group1": {
59
+ "bins": [
60
+ {
61
+ "bin": 0,
62
+ "mean_score": 0.1771,
63
+ "empirical_rate": 0.1628,
64
+ "n": 129
65
+ },
66
+ {
67
+ "bin": 1,
68
+ "mean_score": 0.2457,
69
+ "empirical_rate": 0.2748,
70
+ "n": 222
71
+ },
72
+ {
73
+ "bin": 2,
74
+ "mean_score": 0.2985,
75
+ "empirical_rate": 0.3571,
76
+ "n": 308
77
+ },
78
+ {
79
+ "bin": 3,
80
+ "mean_score": 0.4025,
81
+ "empirical_rate": 0.3916,
82
+ "n": 309
83
+ },
84
+ {
85
+ "bin": 4,
86
+ "mean_score": 0.4675,
87
+ "empirical_rate": 0.4094,
88
+ "n": 320
89
+ },
90
+ {
91
+ "bin": 5,
92
+ "mean_score": 0.5911,
93
+ "empirical_rate": 0.5302,
94
+ "n": 281
95
+ },
96
+ {
97
+ "bin": 6,
98
+ "mean_score": 0.6853,
99
+ "empirical_rate": 0.6687,
100
+ "n": 323
101
+ },
102
+ {
103
+ "bin": 7,
104
+ "mean_score": 0.7958,
105
+ "empirical_rate": 0.7966,
106
+ "n": 349
107
+ }
108
+ ],
109
+ "max_abs_calibration_gap": 0.0609
110
+ }
111
+ },
112
+ "unconstrained_single_threshold": 0.58,
113
+ "unconstrained_utility": 408.0,
114
+ "rows": [
115
+ {
116
+ "eps_fpr_gap": 0.1,
117
+ "group_specific_thresholds": [
118
+ 0.58,
119
+ 0.5
120
+ ],
121
+ "threshold_difference": 0.08,
122
+ "utility_group_specific": 404.0,
123
+ "best_single_threshold": 0.64,
124
+ "utility_single": 352.0,
125
+ "utility_loss_from_single": 52.0
126
+ },
127
+ {
128
+ "eps_fpr_gap": 0.05,
129
+ "group_specific_thresholds": [
130
+ 0.61,
131
+ 0.53
132
+ ],
133
+ "threshold_difference": 0.08,
134
+ "utility_group_specific": 399.0,
135
+ "best_single_threshold": 0.72,
136
+ "utility_single": 243.0,
137
+ "utility_loss_from_single": 156.0
138
+ },
139
+ {
140
+ "eps_fpr_gap": 0.02,
141
+ "group_specific_thresholds": [
142
+ 0.61,
143
+ 0.48
144
+ ],
145
+ "threshold_difference": 0.13,
146
+ "utility_group_specific": 389.0,
147
+ "best_single_threshold": 0.77,
148
+ "utility_single": 96.0,
149
+ "utility_loss_from_single": 293.0
150
+ },
151
+ {
152
+ "eps_fpr_gap": 0.01,
153
+ "group_specific_thresholds": [
154
+ 0.61,
155
+ 0.48
156
+ ],
157
+ "threshold_difference": 0.13,
158
+ "utility_group_specific": 389.0,
159
+ "best_single_threshold": 0.85,
160
+ "utility_single": 48.0,
161
+ "utility_loss_from_single": 341.0
162
+ }
163
+ ],
164
+ "thresholds_differ_in_all": true,
165
+ "single_threshold_costs_utility_in_all": true
166
+ }
167
+ }
pages/claim-5-compas-thresholds/page.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claim 5: a COMPAS case study demonstrates that optimal fair classifiers under sufficiency generally require group-specific decision thresholds rather than a single universal threshold.
2
+
3
+ **Outcome: reproduced on the real COMPAS data. The previous revision stated the
4
+ COMPAS experiment was not rerun and offered synthetic exact cases instead.**
5
+
6
+ ## Data and model
7
+
8
+ ProPublica `compas-scores-two-years` (7,214 records), restricted to the two
9
+ largest groups — African-American (3,696) and Caucasian (2,454). Features: age,
10
+ priors count, juvenile felony/misdemeanour counts, charge degree, sex. Outcome:
11
+ two-year recidivism. 50/50 train/test split, **3,075 test records**.
12
+
13
+ Scores come from isotonic-calibrated logistic regression, because the claim is
14
+ conditional on **sufficiency** — the score must be calibrated within each group
15
+ before "optimal under sufficiency" means anything.
16
+
17
+ ## Sufficiency holds
18
+
19
+ Maximum absolute gap between mean score and empirical recidivism rate, per
20
+ score bin, within each group:
21
+
22
+ - Caucasian: **0.0806**
23
+ - African-American: **0.0609**
24
+
25
+ ## The unconstrained optimum is a single threshold
26
+
27
+ With a calibrated score and no fairness constraint, the utility-maximising rule
28
+ is one threshold shared by both groups: **t = 0.58**, utility
29
+ **408** (true positives minus false positives). This is the baseline the
30
+ claim is contrasted against — sufficiency alone does *not* require group-specific
31
+ thresholds.
32
+
33
+ ## Adding a fairness constraint changes that
34
+
35
+ Constraint: equal false-positive rate across groups to within `eps`. For each
36
+ `eps`, the utility-optimal group-specific pair `(t_AA, t_C)` is found by grid
37
+ search, and separately the best rule *forced* to use one universal threshold.
38
+
39
+ | eps (FPR gap) | optimal (t_AA, t_C) | difference | utility | best single t | utility | **loss from forcing single** |
40
+ | --- | --- | --- | --- | --- | --- | --- |
41
+ | 0.10 | (0.580, 0.500) | 0.080 | 404 | 0.64 | 352.0 | **52.0** |
42
+ | 0.05 | (0.610, 0.530) | 0.080 | 399 | 0.72 | 243.0 | **156.0** |
43
+ | 0.02 | (0.610, 0.480) | 0.130 | 389 | 0.77 | 96.0 | **293.0** |
44
+ | 0.01 | (0.610, 0.480) | 0.130 | 389 | 0.85 | 48.0 | **341.0** |
45
+
46
+ ## Reading
47
+
48
+ **The optimal thresholds differ in every case** — by 0.08 to 0.13 — so the
49
+ constrained optimum is genuinely group-specific, not a single rule in disguise.
50
+
51
+ **Forcing one universal threshold is costly, and the cost grows sharply as the
52
+ constraint tightens**: 52 at `eps=0.10`, rising to **341** at `eps=0.01`.
53
+ Group-specific thresholds retain nearly all the unconstrained utility
54
+ (389-404 against 408), while the best single-threshold rule
55
+ collapses to 48. A universal threshold can only satisfy a tight FPR
56
+ constraint by moving so high that it forgoes almost all true positives.
57
+
58
+ That is the claim's mechanism, on the paper's own dataset: sufficiency alone
59
+ permits one threshold, but an *optimal fair* classifier under an added group
60
+ criterion requires separate ones.
61
+
62
+ ## Scope
63
+
64
+ Utility is true positives minus false positives at equal weight; the fairness
65
+ criterion is equal FPR. Other utility weightings or criteria (demographic parity,
66
+ equalised odds in full) would change the numbers but not the mechanism, which is
67
+ that the constraint binds differently in each group's score distribution.