| """Boundary-tracing routines for the experiments in |
| |
| Etam Benger and Katrina Ligett (2026), "Fair decisions from calibrated |
| scores: Achieving optimal classification while satisfying sufficiency," |
| ICML 2026. |
| """ |
|
|
| import numpy as np |
| from dataclasses import dataclass |
|
|
|
|
| |
| |
| |
|
|
| def _preprocess_scores(s, w, s_tol=1e-6, w_tol=1e-6): |
| s = np.asarray(s, dtype=float) |
| w = np.asarray(w, dtype=float) |
|
|
| if s.shape != w.shape: |
| raise ValueError('s and w must have the same shape') |
| if s.ndim != 1: |
| raise ValueError('s and w must be 1-dimensional') |
| if len(s) < 2: |
| raise ValueError('s and w must have at least 2 elements') |
| if not np.all((0 <= s) & (s <= 1)): |
| raise ValueError('all scores must be in [0, 1]') |
| if not np.all(w >= 0): |
| raise ValueError('all weights must be nonnegative') |
| if np.all(w <= w_tol): |
| raise ValueError('all weights are below tolerance') |
| if not np.isclose(w.sum(), 1, atol=w_tol): |
| raise ValueError('weights must sum to 1') |
|
|
| s_input = s.copy() |
| idx = np.full(len(s), -1, dtype=int) |
|
|
| order = np.argsort(-s) |
| s = s[order] |
| w = w[order] |
|
|
| s_new, w_new = [], [] |
|
|
| for i, si, wi in zip(order, s, w): |
| if wi < w_tol: |
| continue |
|
|
| if s_new and np.isclose(si, s_new[-1], atol=s_tol): |
| w_new[-1] += wi |
| idx[i] = len(s_new) - 1 |
| else: |
| s_new.append(si) |
| w_new.append(wi) |
| idx[i] = len(s_new) - 1 |
|
|
| s_new = np.array(s_new) |
| w_new = np.array(w_new) |
| w_new = w_new / w_new.sum() |
|
|
| if len(s_new) < 2: |
| raise ValueError('s must have at least 2 distinct values with nonnegligible weight') |
|
|
| |
| |
| for i in np.where(idx < 0)[0]: |
| idx[i] = np.argmin(np.abs(s_new - s_input[i])) |
|
|
| return s_new, w_new, idx |
|
|
|
|
| class GroupScoreDistribution: |
| def __init__(self, scores, weights, name=None, s_tol=1e-6, w_tol=1e-6): |
| self.name = name |
| self.s_input = np.asarray(scores, dtype=float) |
| self.w_input = np.asarray(weights, dtype=float) |
|
|
| self.s, self.w, self.idx = _preprocess_scores( |
| self.s_input, self.w_input, s_tol=s_tol, w_tol=w_tol) |
|
|
| self.m = len(self.s) |
| self.pi = np.sum(self.s * self.w) |
|
|
| self.muk = np.cumsum(self.w) |
| ws_cumsum = np.cumsum(self.w * self.s) |
| self.pk = ws_cumsum / self.muk |
| self.qk = np.r_[ |
| (self.pi - ws_cumsum[:-1]) / (1 - self.muk[:-1]), |
| self.s[-1]] |
| self.c = ws_cumsum - self.muk * self.s |
| |
| def __repr__(self): |
| name = f' name = {self.name},\n' if self.name is not None else '' |
| return ( |
| 'GroupScoreDistribution(\n' |
| f'{name}' |
| f' scores = {np.array2string(self.s_input, precision=4, separator=", ")},\n' |
| f' weights = {np.array2string(self.w_input, precision=4, separator=", ")},\n' |
| f' pi = {self.pi:.4f},\n' |
| f' m = {self.m}\n' |
| ')') |
|
|
| def q_boundary(self, p, j): |
| return (p * (self.pi - self.c[j]) - self.pi * self.s[j]) / ( |
| p - self.s[j] - self.c[j]) |
|
|
| def boundary(self, res=1e-3): |
| p = np.array([self.s[0]]) |
| q = np.array([self.pi]) |
|
|
| for j in range(1, self.m): |
| pR = self.pk[j - 1] |
| pL = self.pk[j] |
|
|
| n = max(1, int(np.ceil((pR - pL) / res))) |
| p_ = np.linspace(pR, pL, n, endpoint=False) |
| q_ = self.q_boundary(p_, j) |
|
|
| p = np.r_[p, p_] |
| q = np.r_[q, q_] |
|
|
| p = np.r_[p, self.pi] |
| q = np.r_[q, self.s[-1]] |
|
|
| return p, q |
| |
| def _boundary_params(self, p, q): |
| mu = np.clip((self.pi - q) / (p - q), 1e-6, 1 - 1e-6) |
|
|
| k = np.where(self.muk >= mu)[0][0] |
| ptop = self.s[k] + self.c[k] / mu |
| eta = (p - self.pi) / (ptop - self.pi) |
|
|
| return mu, k, eta |
|
|
| def is_on_boundary(self, p, q): |
| return np.isclose(self._boundary_params(p, q)[2], 1) |
|
|
| def selection_rule(self, p, q, input_order=True): |
| mu, k, eta = self._boundary_params(p, q) |
|
|
| t = np.zeros(self.m) |
| t[:k] = 1 |
| t[k] = 1 - (self.muk[k] - mu) / self.w[k] |
|
|
| if not np.isclose(eta, 1): |
| t = (1 - eta) * mu + eta * t |
|
|
| if input_order: |
| t = t[self.idx] |
|
|
| return t |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class Optimum: |
| value: float |
| p: float |
| q: float |
|
|
|
|
| @dataclass |
| class TraceResult: |
| dist0: GroupScoreDistribution |
| dist1: GroupScoreDistribution |
| prob_a1: float |
| pi_agg: float |
| p: np.ndarray |
| q: np.ndarray |
| max_acc: Optimum |
| min_dsep: Optimum |
|
|
| @property |
| def dists(self): |
| return [self.dist0, self.dist1] |
|
|
| def __repr__(self): |
| dist0_name = self.dist0.name if self.dist0.name is not None else 'dist0' |
| dist1_name = self.dist1.name if self.dist1.name is not None else 'dist1' |
| return ( |
| 'TraceResult(\n' |
| f' A=0: {dist0_name},\n' |
| f' A=1: {dist1_name},\n' |
| f' prob_a1 = {self.prob_a1:.4f},\n' |
| f' pi_agg = {self.pi_agg:.4f},\n' |
| f' max_acc: value = {self.max_acc.value:.4f},\n' |
| f' p = {self.max_acc.p:.4f}, q = {self.max_acc.q:.4f}\n' |
| f' min_dsep: value = {self.min_dsep.value:.4f},\n' |
| f' p = {self.min_dsep.p:.4f}, q = {self.min_dsep.q:.4f}\n' |
| ')') |
|
|
|
|
| |
| |
| |
|
|
| def _solve_quadratic(a, b, c): |
| if np.isclose(a, 0): |
| if np.isclose(b, 0): |
| return [] |
| return [-c / b] |
|
|
| d = b**2 - 4 * a * c |
|
|
| if np.isclose(d, 0): |
| return [-b / (2 * a)] |
| if d < 0: |
| return [] |
|
|
| return [(-b + np.sqrt(d)) / (2 * a), |
| (-b - np.sqrt(d)) / (2 * a)] |
|
|
|
|
| def _right_endpoints(pL, pR, roots, tol): |
| roots = np.sort(np.asarray(roots, dtype=float)) |
| roots = roots[(roots > pL) & (roots < pR)] |
|
|
| pts = np.r_[pL, roots, pR] |
| pts = pts[np.r_[False, np.diff(pts) > tol]][:-1] |
|
|
| return np.r_[pts, pR] |
|
|
|
|
| def _update_max_acc(dist, pi_agg, pL, pR, j, best, tol): |
| D = dist.c[j] - dist.pi + pi_agg |
| E = dist.s[j] * (dist.pi - pi_agg) - dist.c[j] * pi_agg |
|
|
| F = D * (1 - 2 * (dist.pi + dist.s[j])) - 2 * E |
| G = 4 * D * dist.pi * dist.s[j] + 2 * E |
| H = (2 * E - D) * dist.pi * dist.s[j] - E * (dist.pi + dist.s[j]) |
|
|
| roots = _solve_quadratic(F, G, H) |
|
|
| for p in _right_endpoints(pL, pR, roots, tol): |
| acc = 1 - pi_agg + (2 * p - 1) * (D * p + E) / ( |
| (p - dist.pi) * (p - dist.s[j])) |
| if acc > best.value: |
| best = Optimum(acc, p, dist.q_boundary(p, j)) |
|
|
| return best |
|
|
|
|
| def _update_min_dsep(dist, pi_agg, K, pL, pR, j, best, tol): |
| A = dist.pi - pi_agg - dist.c[j] |
| B = pi_agg * (2 * dist.c[j] - dist.pi + dist.s[j] + 1) - dist.s[j] * dist.pi |
| C = pi_agg * (dist.s[j] * dist.pi - dist.s[j] - dist.c[j]) |
|
|
| D = -A * (dist.pi + dist.s[j]) - B |
| E = 2 * A * dist.pi * dist.s[j] - 2 * C |
| F = B * dist.pi * dist.s[j] + C * (dist.pi + dist.s[j]) |
|
|
| roots = _solve_quadratic(D, E, F) |
|
|
| for p in _right_endpoints(pL, pR, roots, tol): |
| dsep = K * (A * p**2 + B * p + C) / ( |
| (p - dist.pi) * (p - dist.s[j])) |
| if dsep < best.value: |
| best = Optimum(dsep, p, dist.q_boundary(p, j)) |
|
|
| return best |
|
|
|
|
| |
| def compute_pmax_qmin(dist0, dist1): |
| dists = [dist0, dist1] |
| pi = np.array([dist0.pi, dist1.pi]) |
|
|
| a = np.argmax(pi) |
| b = 1 - a |
|
|
| k = np.where(dists[a].qk[:-1] <= pi[b])[0][0] |
| if k == 0: |
| p_max = min(dists[b].s[0], dists[a].s[0]) |
| else: |
| p_max = min( |
| dists[b].s[0], |
| (dists[a].s[k] * (pi[a] - pi[b]) - pi[b] * dists[a].c[k]) |
| / (pi[a] - dists[a].c[k] - pi[b])) |
|
|
| k = np.where(dists[b].pk[1:] <= pi[a])[0][0] + 1 |
| if k == dists[b].m - 1: |
| q_min = max(dists[a].s[-1], dists[b].s[-1]) |
| else: |
| q_min = max( |
| dists[a].s[-1], |
| (pi[b] * (pi[a] - dists[b].s[k]) - dists[b].c[k] * pi[a]) |
| / (pi[a] - dists[b].c[k] - dists[b].s[k])) |
|
|
| return p_max, q_min |
|
|
|
|
| |
| def trace_intersection(dist0, dist1, prob_a1, tol=1e-6, res=1e-3): |
| dists = [dist0, dist1] |
| pi = np.array([dist0.pi, dist1.pi]) |
| pi_agg = (1 - prob_a1) * pi[0] + prob_a1 * pi[1] |
|
|
| a = np.argmax(pi) |
| b = 1 - a |
|
|
| if not ((pi[b] > dists[a].s[-1]) and (pi[a] < dists[b].s[0])): |
| raise ValueError('there is no nontrivial intersection boundary') |
|
|
| p_max, q_min = compute_pmax_qmin(dist0, dist1) |
|
|
| acc_pmax = 1 - pi_agg + (2 * p_max - 1) * (pi_agg - pi[b]) / ( |
| p_max - pi[b]) |
| acc_qmin = 1 - pi_agg + (2 * pi[a] - 1) * (pi_agg - q_min) / ( |
| pi[a] - q_min) |
|
|
| if acc_pmax >= acc_qmin: |
| max_acc = Optimum(acc_pmax, p_max, pi[b]) |
| else: |
| max_acc = Optimum(acc_qmin, pi[a], q_min) |
|
|
| K = 2 * prob_a1 * (1 - prob_a1) * np.abs(pi[0] - pi[1]) / ( |
| pi_agg * (1 - pi_agg)) |
|
|
| dsep_pmax = K * (pi_agg * (1 - p_max) + pi[b] * (p_max - pi_agg)) / ( |
| p_max - pi[b]) |
| dsep_qmin = K * (pi_agg * (1 - pi[a]) + q_min * (pi[a] - pi_agg)) / ( |
| pi[a] - q_min) |
|
|
| if dsep_pmax <= dsep_qmin: |
| min_dsep = Optimum(dsep_pmax, p_max, pi[b]) |
| else: |
| min_dsep = Optimum(dsep_qmin, pi[a], q_min) |
|
|
| all_p = np.array([max(pi)]) |
| all_q = np.array([q_min]) |
| pL = pi[a] |
|
|
| while pL < p_max - tol: |
| k = np.where(dist0.pk[:-1] > pL)[0][-1] + 1 |
| l = np.where(dist1.pk[:-1] > pL)[0][-1] + 1 |
| pR = min(dist0.pk[k - 1], dist1.pk[l - 1], p_max) |
|
|
| A0, A1 = dist0.pi - dist0.c[k], dist1.pi - dist1.c[l] |
| B0, B1 = dist0.s[k] + dist0.c[k], dist1.s[l] + dist1.c[l] |
| C0, C1 = dist0.s[k] * dist0.pi, dist1.s[l] * dist1.pi |
|
|
| A = A0 - A1 |
| B = C1 - C0 + B0 * A1 - B1 * A0 |
| C = B1 * C0 - B0 * C1 |
|
|
| roots = _solve_quadratic(A, B, C) |
|
|
| for pR in _right_endpoints(pL, pR, roots, tol): |
| p = (pL + pR) / 2 |
| a = 0 if A * p**2 + B * p + C > 0 else 1 |
| j = k if a == 0 else l |
|
|
| max_acc = _update_max_acc(dists[a], pi_agg, pL, pR, j, max_acc, tol) |
| min_dsep = _update_min_dsep(dists[a], pi_agg, K, pL, pR, j, min_dsep, tol) |
|
|
| n = max(1, int(np.ceil(abs(pR - pL) / res))) |
| p_ = np.linspace(pL, pR, n + 1, endpoint=True)[1:] |
| q_ = dists[a].q_boundary(p_, j) |
|
|
| all_p = np.r_[all_p, p_] |
| all_q = np.r_[all_q, q_] |
|
|
| pL = pR |
|
|
| all_p = np.r_[all_p, p_max] |
| all_q = np.r_[all_q, min(pi)] |
|
|
| return TraceResult( |
| dist0=dist0, |
| dist1=dist1, |
| prob_a1=prob_a1, |
| pi_agg=pi_agg, |
| p=all_p, |
| q=all_q, |
| max_acc=max_acc, |
| min_dsep=min_dsep) |
|
|