SabaPivot's picture
download
raw
13.4 kB
"""
Common machinery for the reproduction of
"Provably Data-driven Lagrangian Relaxation for Mixed Integer Linear Programming"
(arXiv 2605.19052, OpenReview OwLuqetJuB).
Everything is CPU-only and uses scipy / HiGHS.
Design
------
A MILP instance is P = (c, A, b, C, d) with
OPT(P) = min c'x s.t. x in R^m_+ x {0,1}^p, Ax >= b (s coupling rows), Cx >= d
u(pi,P)= min c'x + pi'(b - Ax) s.t. x in X = {x in R^m_+ x {0,1}^p : Cx >= d}
We use *block-decomposable local constraints*: C is block diagonal in
(y continuous, z binary), i.e. C_y y >= d_y and C_z z >= d_z, plus a box 0 <= y <= ymax.
This is exactly the structure the paper motivates (independent sub-problems linked
only by the s coupling rows Ax >= b).
Because the objective c'x + pi'(b-Ax) is linear in x, its minimum over the (non-convex)
set X is attained on the finite set
Cand(P) = vert(Y) x {z in {0,1}^p : C_z z >= d_z},
where vert(Y) is the vertex set of the (bounded) polytope Y. We enumerate that finite
set once per instance, so u(pi,P) and its subgradient become exact O(1) linear algebra
for *any* pi -- no solver in the inner loop. `verify_against_highs` cross-checks the
enumeration against `scipy.optimize.milp` (HiGHS) on random (pi,P) pairs.
For the finite candidate representation we store, per instance i and candidate j,
a[i,j] = c' x_j (constant part)
G[i,j,:] = b - A x_j (subgradient / linear part)
so that u(pi,P_i) = min_j ( a[i,j] + G[i,j,:] @ pi ).
"""
import itertools
import numpy as np
from scipy.optimize import linprog, milp, LinearConstraint, Bounds
import scipy.sparse as sp
# --------------------------------------------------------------------------------------
# vertex enumeration of a small bounded polytope Y = {y : 0 <= y <= ymax, Cy y >= dy}
# --------------------------------------------------------------------------------------
def polytope_vertices(Cy, dy, ymax, tol=1e-9):
"""Brute-force vertex enumeration of {y in R^m : Cy y >= dy, 0 <= y <= ymax}."""
m = ymax.shape[0]
# write everything as H y <= h
H = np.vstack([-Cy, -np.eye(m), np.eye(m)])
h = np.concatenate([-dy, np.zeros(m), ymax])
verts = []
n = H.shape[0]
for idx in itertools.combinations(range(n), m):
Hs, hs = H[list(idx)], h[list(idx)]
if abs(np.linalg.det(Hs)) < 1e-8:
continue
y = np.linalg.solve(Hs, hs)
if np.all(H @ y <= h + 1e-7):
verts.append(y)
if not verts:
return np.zeros((0, m))
V = np.unique(np.round(np.array(verts), 9), axis=0)
return V
# --------------------------------------------------------------------------------------
# instance generation
# --------------------------------------------------------------------------------------
def gen_instance(rng, s, m=2, p=6, n_local_y=2, n_local_z=2, ymax_val=1.0):
"""Draw one random MILP instance with s coupling constraints. Returns a dict."""
# ---- local (sub-problem) constraints -------------------------------------------
Cy = rng.uniform(0.2, 1.0, size=(n_local_y, m))
dy = rng.uniform(0.1, 0.5, size=n_local_y) * m * 0.5
ymax = np.full(m, ymax_val)
V = polytope_vertices(Cy, dy, ymax)
if V.shape[0] == 0:
return None
Cz = rng.uniform(0.2, 1.0, size=(n_local_z, p))
dz = rng.uniform(0.1, 0.4, size=n_local_z) * p * 0.5
Zall = np.array(list(itertools.product([0, 1], repeat=p)), dtype=float)
Zfeas = Zall[np.all(Zall @ Cz.T >= dz - 1e-12, axis=1)]
if Zfeas.shape[0] == 0:
return None
# ---- objective and coupling constraints -----------------------------------------
c = rng.uniform(0.2, 1.2, size=m + p)
A = rng.uniform(0.0, 1.0, size=(s, m + p))
# candidate set
ny, nz = V.shape[0], Zfeas.shape[0]
X = np.concatenate(
[np.repeat(V, nz, axis=0), np.tile(Zfeas, (ny, 1))], axis=1
) # (ny*nz, m+p)
AX = X @ A.T # (ncand, s)
# choose b so that the coupling constraints are active-ish (b = median of AX rows)
b = np.quantile(AX, 0.6, axis=0)
inst = dict(
c=c,
A=A,
b=b,
Cy=Cy,
dy=dy,
Cz=Cz,
dz=dz,
ymax=ymax,
m=m,
p=p,
s=s,
X=X,
a=X @ c,
G=b[None, :] - AX,
)
# exact B of Assumption 4.1 for this instance
inst["B"] = float(max(np.max(np.abs(b)), np.max(np.abs(AX))))
return inst
def u_of(inst, pi):
"""Exact u(pi,P) and a subgradient g = b - A x*(pi,P) via the finite candidate set."""
vals = inst["a"] + inst["G"] @ pi
j = int(np.argmin(vals))
return float(vals[j]), inst["G"][j].copy(), inst["X"][j].copy()
def opt_of(inst):
"""OPT(P): min c'x over candidates that additionally satisfy A x >= b."""
ok = np.all(inst["G"] <= 1e-12, axis=1) # b - Ax <= 0 <=> Ax >= b
if not np.any(ok):
return np.inf
return float(np.min(inst["a"][ok]))
# --------------------------------------------------------------------------------------
# HiGHS cross-check
# --------------------------------------------------------------------------------------
def u_highs(inst, pi):
"""u(pi,P) computed independently with scipy.optimize.milp (HiGHS)."""
m, p = inst["m"], inst["p"]
n = m + p
obj = inst["c"] - inst["A"].T @ pi
const = float(pi @ inst["b"])
# local constraints only: Cy y >= dy , Cz z >= dz
rows = []
lb = []
Cy, Cz = inst["Cy"], inst["Cz"]
for k in range(Cy.shape[0]):
r = np.zeros(n)
r[:m] = Cy[k]
rows.append(r)
lb.append(inst["dy"][k])
for k in range(Cz.shape[0]):
r = np.zeros(n)
r[m:] = Cz[k]
rows.append(r)
lb.append(inst["dz"][k])
A_ub = np.array(rows)
cons = LinearConstraint(A_ub, np.array(lb), np.inf)
integrality = np.concatenate([np.zeros(m), np.ones(p)])
bounds = Bounds(np.zeros(n), np.concatenate([inst["ymax"], np.ones(p)]))
res = milp(c=obj, constraints=cons, integrality=integrality, bounds=bounds)
assert res.success, res.message
return float(res.fun + const), res.x
def opt_highs(inst):
"""OPT(P) with HiGHS (coupling constraints included)."""
m, p = inst["m"], inst["p"]
n = m + p
rows, lb = [], []
Cy, Cz = inst["Cy"], inst["Cz"]
for k in range(Cy.shape[0]):
r = np.zeros(n)
r[:m] = Cy[k]
rows.append(r)
lb.append(inst["dy"][k])
for k in range(Cz.shape[0]):
r = np.zeros(n)
r[m:] = Cz[k]
rows.append(r)
lb.append(inst["dz"][k])
for k in range(inst["s"]):
rows.append(inst["A"][k])
lb.append(inst["b"][k])
cons = LinearConstraint(np.array(rows), np.array(lb), np.inf)
integrality = np.concatenate([np.zeros(m), np.ones(p)])
bounds = Bounds(np.zeros(n), np.concatenate([inst["ymax"], np.ones(p)]))
res = milp(c=inst["c"], constraints=cons, integrality=integrality, bounds=bounds)
if not res.success:
return np.inf
return float(res.fun)
# --------------------------------------------------------------------------------------
# pooled representation of a whole population of instances (padded to a common K)
# --------------------------------------------------------------------------------------
class Pool:
"""A finite population D = Uniform{P_1,...,P_M}. Exact population risks."""
def __init__(self, insts, pimax):
self.M = len(insts)
self.s = insts[0]["s"]
self.pimax = float(pimax)
K = max(i["a"].shape[0] for i in insts)
self.K = K
self.a = np.full((self.M, K), 1e18)
self.G = np.zeros((self.M, K, self.s))
for i, inst in enumerate(insts):
k = inst["a"].shape[0]
self.a[i, :k] = inst["a"]
self.G[i, :k] = inst["G"]
self.B = float(max(i["B"] for i in insts))
self.insts = insts
def u_all(self, pi, idx=None):
"""Vector of u(pi,P_i) (and argmin candidate) for the given instance indices."""
a = self.a if idx is None else self.a[idx]
G = self.G if idx is None else self.G[idx]
vals = a + G @ pi
j = np.argmin(vals, axis=1)
return vals[np.arange(vals.shape[0]), j], j
def grad_all(self, pi, idx=None):
vals, j = self.u_all(pi, idx)
G = self.G if idx is None else self.G[idx]
g = G[np.arange(G.shape[0]), j]
return vals, g
def F(self, pi, idx=None, w=None):
"""Weighted average utility (w defaults to uniform over idx)."""
vals, _ = self.u_all(pi, idx)
if w is None:
return float(vals.mean())
return float(vals @ w)
# --------------------------------------------------------------------------------------
# exact maximisation of a weighted average of concave piecewise-linear u's (cutting plane)
# --------------------------------------------------------------------------------------
def maximize_weighted(pool, idx, w, tol=1e-10, max_iter=200):
"""Exact max_{pi in [0,pimax]^s} sum_i w_i u(pi, P_{idx[i]}) by Kelley cutting planes.
The objective is a weighted sum of concave piecewise-linear functions, hence concave
piecewise linear; Kelley's method with an LP master terminates finitely and exactly.
Returns (pi_star, value, n_iters).
"""
n = len(idx)
s = pool.s
pimax = pool.pimax
a = pool.a[idx]
G = pool.G[idx]
# active cut lists
cuts = [[] for _ in range(n)]
pi = np.full(s, pimax / 2.0)
vals, j = pool.u_all(pi, idx)
for i in range(n):
cuts[i].append(j[i])
# LP variables: [t_1..t_n, pi_1..pi_s]; maximise sum w_i t_i -> minimise -sum w_i t_i
cobj = np.concatenate([-w, np.zeros(s)])
bounds = [(None, None)] * n + [(0.0, pimax)] * s
for it in range(max_iter):
rows_i, rows_j, data, rhs = [], [], [], []
r = 0
for i in range(n):
for jj in cuts[i]:
# t_i - G[i,jj]@pi <= a[i,jj]
rows_i.append(r)
rows_j.append(i)
data.append(1.0)
for k in range(s):
if G[i, jj, k] != 0.0:
rows_i.append(r)
rows_j.append(n + k)
data.append(-G[i, jj, k])
rhs.append(a[i, jj])
r += 1
Aub = sp.csr_matrix((data, (rows_i, rows_j)), shape=(r, n + s))
res = linprog(cobj, A_ub=Aub, b_ub=np.array(rhs), bounds=bounds, method="highs")
assert res.success, res.message
pi = res.x[n:]
t = res.x[:n]
vals, j = pool.u_all(pi, idx)
viol = t - vals # >0 means the master over-estimates u_i
added = 0
for i in np.where(viol > tol)[0]:
if j[i] not in cuts[i]:
cuts[i].append(int(j[i]))
added += 1
if added == 0:
return pi, float(vals @ w), it + 1
return pi, float(vals @ w), max_iter
def erm(pool, sample_idx):
"""Exact ERM maximiser on a multiset `sample_idx` of pool instances."""
uniq, cnt = np.unique(sample_idx, return_counts=True)
w = cnt / cnt.sum()
return maximize_weighted(pool, uniq, w)
def population_opt(pool):
idx = np.arange(pool.M)
w = np.full(pool.M, 1.0 / pool.M)
return maximize_weighted(pool, idx, w)
# --------------------------------------------------------------------------------------
# Algorithm 1 : stochastic (sub)gradient ascent with iterate averaging
# --------------------------------------------------------------------------------------
def sga(pool, stream_idx, eta, average=True, pi0=None):
"""Algorithm 1 of the paper. Returns the averaged (or last) iterate."""
s = pool.s
pimax = pool.pimax
pi = np.zeros(s) if pi0 is None else pi0.copy()
acc = np.zeros(s)
for t, i in enumerate(stream_idx):
acc += pi
vals = pool.a[i] + pool.G[i] @ pi
j = int(np.argmin(vals))
g = pool.G[i, j] # unbiased stochastic supergradient
pi = np.clip(pi + eta * g, 0.0, pimax) # Proj_Pi ( pi + eta g )
if average:
return acc / len(stream_idx)
return pi
# --------------------------------------------------------------------------------------
# log-log fitting helper
# --------------------------------------------------------------------------------------
def loglog_fit(x, y):
x = np.asarray(x, float)
y = np.asarray(y, float)
ok = (x > 0) & (y > 0)
A = np.vstack([np.log(x[ok]), np.ones(ok.sum())]).T
coef, res, *_ = np.linalg.lstsq(A, np.log(y[ok]), rcond=None)
pred = A @ coef
ss_res = float(np.sum((np.log(y[ok]) - pred) ** 2))
ss_tot = float(np.sum((np.log(y[ok]) - np.log(y[ok]).mean()) ** 2))
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
return float(coef[0]), float(np.exp(coef[1])), r2
def dump_json(path, obj):
import json
def default(o):
if isinstance(o, (np.floating,)):
return float(o)
if isinstance(o, (np.integer,)):
return int(o)
if isinstance(o, np.ndarray):
return o.tolist()
raise TypeError(str(type(o)))
with open(path, "w") as f:
json.dump(obj, f, indent=2, default=default)
print("wrote", path)

Xet Storage Details

Size:
13.4 kB
·
Xet hash:
f569b17e3c3ba571e3ab75384214459e03943db7576c3a524ef69898c3e91b5c

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.