Repository navigation
Risk_weeds
Source: src/Risk.py · Theory: Scenario Theory · Where get_support
quantify_risk(k, N, beta) turns the complexity
These are the two-sided bounds of Garatti and Campi, Risk and complexity in scenario optimization, Math. Programming 191 (2022). They are computed by bisection on a reformulation that uses the regularized incomplete beta function, following Campi and Garatti, Compression, generalization and learning, JMLR 24 (2023), Appendix B. The function has no notion of degeneracy; see Degeneracy.
from src.Risk import quantify_risk
epsL, epsU = quantify_risk(k, N, beta)| Name | Type | Meaning |
|---|---|---|
k |
int, |
Complexity, the cardinality of the support list. This is the complexity returned by solve_lp, solve_qp or solve_sdp. Integral floats such as 2.0 give the same result. |
N |
int, |
Number of scenarios. |
beta |
float, |
Confidence parameter. The bounds hold with probability at least 1e-6 to 1e-3. |
The inputs are validated before any computation: ValueError is raised unless N is an integer k is an integer between 0 and N, and
| Name | Type | Meaning |
|---|---|---|
epsL |
float |
Lower bound 0.0 whenever the theoretical lower bound is k == 0. |
epsU |
float |
Upper bound 1.0 when k == N, and also when the upper root lies within k=99, N=100, beta=1e-6). |
Both values are plain Python floats.
For
It has exactly two roots
Instead of summing the polynomial's binomial terms (up to
where jax.scipy.special.betainc(a, b, x)).
Why the two equations agree. Write
The first is the negative-binomial identity. Multiply the polynomial by
So (beta/2 - beta/6). This equals
The same equation also covers the edge cases. When betainc returns exactly these limits (betainc(11, 0, 0.3) = 0.0 and betainc(0, 11, 0.3) = 1.0), whereas scipy.special.betainc returns nan for both. Replacing the JAX function with SciPy's would break the k = 0 and k = N cases.
threshold = 1e-10
# lower bound: search [0, k/N]
t1, t2 = 0.0, k/N
while t2 - t1 > threshold:
t = (t1 + t2) / 2
if L(t) > R(t): t1 = t # still left of the lower root
else: t2 = t
epsL = t1 # left end, so rounded down
# upper bound: search [k/N, 1], skipped when k == N (then epsU = 1)
t1, t2 = k/N, 1
while t2 - t1 > threshold:
t = (t1 + t2) / 2
if L(t) > R(t): t2 = t # right of the upper root
else: t1 = t
epsU = t2 # right end, so rounded up-
Brackets. The search assumes that the empirical violation frequency
$k/N$ lies between the two roots, that is$R(k/N) \ge L(k/N)$ . This held in every case checked below. -
Rounding. Both results are rounded outwards, by at most
$10^{-10}$ :epsLis the left end of its final interval andepsUthe right end. -
No lower root. If
$R \ge L$ on all of$(0, k/N]$ , which happens when the theoretical lower bound is$\le 0$ , the lower loop shrinks to 0 and returnsepsL = 0.0. This reproduces the$\max\lbrace 0, \cdot\rbrace$ . -
Iteration count. The loops run
$\lceil \log_2((k/N)/10^{-10}) \rceil$ and$\lceil \log_2((1-k/N)/10^{-10}) \rceil$ times, so at most 34 each. For example,$k=2$ ,$N=200$ takes 27 + 34 iterations. Each iteration makes fourbetainccalls.
quantify_risk runs the bisection inside jax.experimental.enable_x64(), so JAX computes in float64. Without it, JAX computes in float32. According to the source comment, src.Risk does not change JAX's global configuration, and other JAX code in the same process keeps the float32 default.
The table compares quantify_risk with roots of the polynomial computed independently. Those roots were found by bisection in 80-digit decimal arithmetic with exact integer binomials. The last column is the one-sided bound quantify_risk does not compute it.
epsL |
exact |
epsU |
exact |
one-sided |
|||
|---|---|---|---|---|---|---|---|
| 0 | 100 | 1e-6 | 0 | 0 | 0.1586324059 | 0.1586324059 | 0.15244298 |
| 5 | 100 | 1e-6 | 0 | 0 | 0.2653136026 | 0.2653136027 | 0.25876431 |
| 20 | 100 | 1e-6 | 0.0432916579 | 0.0432916580 | 0.4718639313 | 0.4718639313 | 0.46530794 |
| 60 | 100 | 1e-6 | 0.3194619689 | 0.3194619689 | 0.8398057520 | 0.8398057520 | 0.83581827 |
| 99 | 100 | 1e-6 | 0.7925930397 | 0.7925930398 | 1 | 0.9999999999 | ≈ 1 |
| 100 | 100 | 1e-6 | 0.8186299158 | 0.8186299159 | 1 | 1 | 1 |
| 0 | 1000 | 1e-6 | 0 | 0 | 0.0172040952 | 0.0172040952 | 0.01648037 |
| 10 | 1000 | 1e-6 | 0 | 0 | 0.0393671338 | 0.0393671338 | 0.03843961 |
| 50 | 1000 | 1e-3 | 0.0258867719 | 0.0258867720 | 0.0851435092 | 0.0851435092 | 0.08354206 |
| 200 | 1000 | 1e-6 | 0.1333601984 | 0.1333601985 | 0.2792148898 | 0.2792148898 | 0.27742067 |
In every row the code agrees with the exact roots to within
quantify_risk does not take the degeneracy flag and always returns both bounds. The two-sided certificate needs non-degeneracy, meaning the support list is unique. So when solve_lp, solve_qp or solve_sdp returns degeneracy=True:
-
Do not use
epsL. It is not certified. -
epsUcan still be used as an upper bound. In every case in the table above, the two-sidedepsUwas larger, and therefore more conservative, than the one-sided bound$\varepsilon(k)$ that holds without non-degeneracy. -
kitself may be larger than the true complexity. Pruning finds an irreducible support list, but not necessarily a smallest one. A larger$k$ only makesepsUmore conservative.
The web interface's results table has a row "Degeneracy Detected?" (Yes/No). When it says Yes, the "Risk Bounds" row shows the lower bound as N/A (e.g. [N/A, 0.1414]) and the ρ/τ graphs leave it out. The downloaded risk field still holds both numbers.
| Input | Behaviour |
|---|---|
k == 0 |
The lower interval is [0, 0], so the loop is skipped and epsL = 0.0. The upper search uses |
k == N |
epsU = 1.0 with no search. The lower search uses |
| Upper root within |
epsU stays at its starting value, 1.0. |
Integral floats, e.g. k=2.0
|
Accepted and converted to int. |
k < 0, k > N or non-integral k
|
ValueError, e.g. k must be an integer between 0 and N = 3 (got 5).
|
N < 1 or non-integral N
|
ValueError, e.g. N must be a positive integer (got 0).
|
beta outside |
ValueError, e.g. beta must lie strictly between 0 and 1 (got 0).
|
| Step | Measured cost |
|---|---|
import jax together with src.Risk
|
about 0.3 s |
| First call in a process | about 200 ms (JAX warm-up) |
| Later calls | about 20–35 ms each |
The run time does not depend on quantify_risk(5000, 1_000_000, 1e-6) took 32 ms. This is because betainc is a special-function evaluation rather than a sum over risk_time.
For each solve_form) calls quantify_risk(complexity, N, beta) with the
Run from the repository root:
import time
from src.Risk import quantify_risk
for k, N, beta in [(2, 200, 1e-6), (0, 200, 1e-6), (10, 200, 1e-6), (200, 200, 1e-6), (25, 1000, 1e-3)]:
t0 = time.perf_counter()
epsL, epsU = quantify_risk(k, N, beta)
dt = time.perf_counter() - t0
print(f"k={k:>3} N={N:>4} beta={beta:.0e}: epsL={epsL:.6f} epsU={epsU!r:<22} ({dt*1e3:.0f} ms)")Output:
k= 2 N= 200 beta=1e-06: epsL=0.000000 epsU=0.11005086670164022 (223 ms)
k= 0 N= 200 beta=1e-06: epsL=0.000000 epsU=0.0829441822716035 (21 ms)
k= 10 N= 200 beta=1e-06: epsL=0.000000 epsU=0.18516078560496682 (33 ms)
k=200 N= 200 beta=1e-06: epsL=0.901218 epsU=1.0 (18 ms)
k= 25 N=1000 beta=1e-03: epsL=0.009714 epsU=0.05192692162963794 (33 ms)
What the output shows:
- The first call includes the JAX warm-up, which is why it takes longer.
- For
k == N,epsUis1.0, whileepsLis still informative. - The first and third rows match the robust and relaxed runs of the LP example, which have
$k = 2$ and$k = 10$ with$N = 200$ .
- Scenario Theory: the definitions of risk, support list, complexity and non-degeneracy.
-
Miscellaneous.py →
get_support: how$k$ and the degeneracy flag are obtained. -
LP.py, QP.py, SDP.py: the solvers that produce
(complexity, N, degeneracy).