ProCreations's picture
Map exact current anchored claims to existing independent evidence
ed2a4b0 verified
Raw
History Blame Contribute Delete
10.6 kB
"""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
# -----------------------------------------------------------------------------
# Score distributions
# -----------------------------------------------------------------------------
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')
# Discarded tiny-weight bins do not affect the optimization, but this keeps
# selection rules well-defined in the original input order.
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
# -----------------------------------------------------------------------------
# Results
# -----------------------------------------------------------------------------
@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'
')')
# -----------------------------------------------------------------------------
# Intersection boundary tracing
# -----------------------------------------------------------------------------
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
# Algorithm 2 in the paper.
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
# Algorithm 1 in the paper.
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)