Pick-to-Learn Scenario Compression for Safe NN Calibration / p2l_experiment.py
Mechanism confirmed, baseline not beaten
1import json
2import math
3import random
4from pathlib import Path
5import numpy as np
6from scipy.optimize import brentq, minimize
7from scipy.special import logsumexp
8
9SEED = 17
10rng = np.random.default_rng(SEED)
11
12
13def logcomb(n, k):
14 return math.lgamma(n + 1) - math.lgamma(k + 1) - math.lgamma(n - k + 1)
15
16
17def epsilon_exact(N, k, beta):
18 """Theorem 1 epsilon: solve beta/N * sum(...) = 1."""
19 if k >= N:
20 return 1.0
21 logs = np.array([logcomb(m, k) - logcomb(N, k) for m in range(k, N)])
22 powers = np.arange(1, N - k + 1) # N-m
23 def f(e):
24 z = logs - powers * math.log1p(-e)
25 log_value = math.log(beta / N) + logsumexp(z)
26 # Monotone residual in log-space; root is log_value == 0.
27 return log_value
28 lo = k / N
29 # For sufficiently large k, the monotone equation reaches one at the
30 # admissible endpoint; the interval bound is then the active certificate.
31 a = lo + 1e-12
32 if f(a) >= 0:
33 return float(lo)
34 return float(brentq(f, a, 1 - 1e-12, xtol=1e-12))
35
36
37def calibrate(A, y, ids):
38 """Minimum L2-norm theta satisfying selected linear constraints A theta >= y."""
39 if len(ids) == 0:
40 return np.zeros(A.shape[1])
41 As, ys = A[ids], y[ids]
42 fun = lambda t: 0.5 * float(t @ t)
43 jac = lambda t: t
44 cons = {'type': 'ineq', 'fun': lambda t: As @ t - ys,
45 'jac': lambda t: As}
46 out = minimize(fun, np.zeros(A.shape[1]), jac=jac, constraints=cons,
47 method='SLSQP', options={'maxiter': 300, 'ftol': 1e-11})
48 if not out.success:
49 raise RuntimeError(out.message)
50 return out.x
51
52
53def compress(A, y, k, mode, seed):
54 local = np.random.default_rng(seed)
55 chosen = []
56 for _ in range(k):
57 t = calibrate(A, y, chosen)
58 violation = y - A @ t
59 candidates = np.array([i for i in range(len(y)) if i not in chosen])
60 if mode == 'greedy':
61 i = int(candidates[np.argmax(violation[candidates])])
62 else:
63 i = int(local.choice(candidates))
64 chosen.append(i)
65 return np.array(chosen), calibrate(A, y, chosen)
66
67
68def run_toy():
69 # Scenario uncertainty is iid: each constraint is a random affine requirement.
70 n, nt, k = 400, 50000, 2
71 train = rng.uniform(-1, 1, size=n)
72 test = rng.uniform(-1, 1, size=nt)
73 # Convex envelope-like constraints; two extreme slopes are informative.
74 A = np.column_stack([train, np.ones(n)])
75 y = 0.34 + 0.42 * np.abs(train) + rng.normal(0, 0.018, n)
76 At = np.column_stack([test, np.ones(nt)])
77 yt = 0.34 + 0.42 * np.abs(test) + rng.normal(0, 0.018, nt)
78 rows = []
79 for kk in [2, 4, 8]:
80 for mode in ['greedy', 'random']:
81 chosen, theta = compress(A, y, kk, mode, SEED + kk)
82 train_rate = float(np.mean(y - A @ theta > 1e-8))
83 fresh_rate = float(np.mean(yt - At @ theta > 0))
84 rows.append({'k': kk, 'method': mode, 'train_violation': train_rate,
85 'fresh_violation': fresh_rate, 'theta': theta.tolist(),
86 'selected': chosen.tolist()})
87 # Non-iid stress test: shifted fresh distribution should not be called certified.
88 shifted = rng.uniform(0.55, 1.0, size=nt)
89 Ash = np.column_stack([shifted, np.ones(nt)])
90 ysh = 0.34 + 0.42 * np.abs(shifted) + rng.normal(0, 0.018, nt)
91 chosen, theta = compress(A, y, 2, 'greedy', SEED)
92 shifted_rate = float(np.mean(ysh - Ash @ theta > 0))
93 return rows, shifted_rate
94
95
96def main():
97 # Cheap math verification and explicit quantitative predictions.
98 checks = []
99 for N in [100, 200, 400, 800]:
100 checks.append({'N': N, 'k': 2, 'beta': 1e-5, 'epsilon': epsilon_exact(N, 2, 1e-5)})
101 beta_rows = [{'beta': b, 'epsilon': epsilon_exact(400, 2, b)}
102 for b in [1e-3, 1e-5, 1e-7]]
103 k_rows = [{'k': k, 'epsilon': epsilon_exact(400, k, 1e-5)}
104 for k in [0, 1, 2, 4, 8, 16]]
105 toy, shifted = run_toy()
106 result = {'seed': SEED, 'theorem_checks': checks, 'beta_sweep': beta_rows,
107 'k_sweep': k_rows, 'toy': toy, 'shifted_fresh_violation_k2_greedy': shifted}
108 Path('results.json').write_text(json.dumps(result, indent=2))
109 print(json.dumps(result, indent=2))
110
111if __name__ == '__main__':
112 main()