from typing import Any
import numpy as np
from scipy import optimize
from gpkit import VectorVariable, Variable, Model, SignomialsEnabled
from gpkit.constraints.bounded import Bounded, ConstraintSet
from .program import OptimizationProblem
[docs]
class SONCRelaxations(object):
r"""
Framework for constrained SONC relaxations formulated as geometric programs.
"""
def __init__(self, prog: OptimizationProblem, **kwargs) -> None:
self.prog = prog
self.program_size = len(prog.constraints) + 1
# Section 3 uses G(mu) = f - sum_i mu_i * g_i.
self.g = [prog.objective] + [-c for c in prog.constraints]
self.Ord = self.prog.program_degree()
self.error_bound = kwargs.get('error_bound', 1e-10)
self.max_bound = kwargs.get('max_bound', 1e10)
self.verbosity = kwargs.get('verbosity', 1)
self.use_local_solve = kwargs.get('use_local_solve', True)
self.solution = None
self.f_sonc_g = None
@staticmethod
def _append_symbolic_constraint(constraints: list, constraint: Any) -> None:
"""Append only symbolic constraints and ignore plain boolean results."""
if isinstance(constraint, (bool, np.bool_)):
return
constraints.append(constraint)
def _build_delta_sets(self) -> dict[str, set]:
delta = self.prog.delta(self.prog.objective, self.Ord)
for xprsn in self.prog.constraints:
xp_delta = self.prog.delta(-xprsn, self.Ord)
delta = {
'=d': delta['=d'].union(xp_delta['=d']),
'<d': delta['<d'].union(xp_delta['<d'])
}
return delta
def _build_support_points(self) -> tuple[list[tuple[int, ...]], list, int, bool]:
supports = []
hull_computed = False
try:
self.prog.newton()
if self.prog.vertices:
supports = [tuple(int(v) for v in point) for point in self.prog.vertices]
hull_computed = True
except Exception:
# Fallback for lower-dimensional supports where ConvexHull may fail.
supports = []
if not supports:
exponents = set()
for coeff, mono in self.prog.objective.content:
_ = coeff
exponents.add(tuple(self.prog.mono2ord_tuple(mono)))
for xprsn in self.prog.constraints:
for coeff, mono in xprsn.content:
_ = coeff
exponents.add(tuple(self.prog.mono2ord_tuple(mono)))
supports = sorted(exponents)
n = len(self.prog.semigroup.generators)
origin = tuple([0] * n)
if origin not in supports:
supports = [origin] + supports
alpha = [self.prog.tuple2mono(pt) for pt in supports]
origin_idx = supports.index(origin)
return supports, alpha, origin_idx, hull_computed
def _convex_combination(self, point: tuple[int, ...], supports: list[tuple[int, ...]]) -> np.ndarray | None:
np_vertices = np.array(supports, dtype=float)
A_eq = np_vertices.T
b_eq = np.array(point, dtype=float)
A_ub = -np.identity(np_vertices.shape[0])
b_ub = np.zeros(np_vertices.shape[0])
additional_eq_constraint = np.ones((1, np_vertices.shape[0]))
A_eq = np.vstack([A_eq, additional_eq_constraint])
b_eq = np.append(b_eq, 1.0)
result = optimize.linprog(
c=np.zeros(np_vertices.shape[0]),
A_eq=A_eq,
b_eq=b_eq,
A_ub=A_ub,
b_ub=b_ub,
bounds=(0, None),
method='highs'
)
if result.success:
return result.x
return None
def _build_beta_info(
self,
beta_terms: list,
supports: list[tuple[int, ...]],
origin_idx: int
) -> dict:
beta_info = {}
for beta in beta_terms:
beta_tuple = self.prog.mono2ord_tuple(beta)
lambdas = self._convex_combination(beta_tuple, supports)
if lambdas is None:
raise RuntimeError(f"Unable to express beta={beta} as convex combination of support points")
lambdas = np.where(lambdas < self.error_bound, 0.0, lambdas)
s = float(np.sum(lambdas))
if s <= 0:
raise RuntimeError(f"Degenerate convex-combination weights for beta={beta}")
lambdas = lambdas / s
nz = [idx for idx, val in enumerate(lambdas) if val > self.error_bound]
if not nz:
raise RuntimeError(f"No positive convex-combination weights for beta={beta}")
beta_info[beta] = {
'tuple': beta_tuple,
'lambdas': lambdas,
'nz': nz,
'lambda0': float(lambdas[origin_idx])
}
return beta_info
def _initialize_variables(self, beta_terms: list, beta_info: dict, origin_idx: int) -> tuple[Any, dict, dict]:
mu = VectorVariable(self.program_size, 'mu', '', "Lagrangian coefficients")
a = {}
b = {}
for idx, beta in enumerate(beta_terms):
b[beta] = Variable(f'b_{idx}')
for j in beta_info[beta]['nz']:
if j == origin_idx:
continue
a[(beta, j)] = Variable(f'a_{idx}_{j}')
return mu, a, b
def _add_variable_bounds(self, constraints: list, mu: Any, a: dict, b: dict) -> None:
for j in range(self.program_size):
constraints.append(mu[j] >= self.error_bound)
if j > 0:
constraints.append(mu[j] <= self.max_bound)
for var in a.values():
constraints.append(var >= self.error_bound)
constraints.append(var <= self.max_bound)
for var in b.values():
constraints.append(var >= self.error_bound)
constraints.append(var <= self.max_bound)
def _g_split(self, mu: Any, mono: Any) -> tuple[Any, Any]:
"""Split G(mu)_mono into positive and negative parts.
G(mu) = f - sum_i mu_i * g_i where g_i are original constraints.
"""
g_plus = 0.0
g_minus = 0.0
f_coeff = self.prog.objective[mono]
if f_coeff > 0:
g_plus = g_plus + f_coeff
elif f_coeff < 0:
g_minus = g_minus + (-f_coeff)
for i, cns in enumerate(self.prog.constraints, start=1):
coeff = cns[mono]
# Contribution is -(coeff * mu[i]).
if coeff > 0:
g_minus = g_minus + coeff * mu[i]
elif coeff < 0:
g_plus = g_plus + (-coeff) * mu[i]
return g_plus, g_minus
def _build_objective(self, mu: Any, a: dict, b: dict, beta_terms: list, beta_info: dict, origin_idx: int) -> Any:
obj = 0.0
alpha0 = self.prog.semigroup.G.identity
for i, cns in enumerate(self.prog.constraints, start=1):
g_i_plus_alpha0 = max(0.0, cns[alpha0])
obj = obj + mu[i] * g_i_plus_alpha0
for beta in beta_terms:
lambda0 = beta_info[beta]['lambda0']
if lambda0 <= self.error_bound:
continue
term = b[beta] ** (1.0 / lambda0)
for j in beta_info[beta]['nz']:
if j == origin_idx:
continue
lam = float(beta_info[beta]['lambdas'][j])
if lam <= self.error_bound:
continue
term = term * (lam / a[(beta, j)]) ** (lam / lambda0)
obj = obj + lambda0 * term
return obj
def _build_constraints(
self,
constraints: list,
mu: Any,
a: dict,
b: dict,
beta_terms: list,
beta_info: dict,
alpha: list,
origin_idx: int
) -> None:
constraints.append(mu[0] == 1.0)
# Constraint family (1): sum_j a_{beta,j} <= G(mu)_{alpha(j)} with sign split.
for j, alpha_j in enumerate(alpha):
if j == origin_idx:
continue
lhs = 0.0
for beta in beta_terms:
if (beta, j) in a:
lhs = lhs + a[(beta, j)]
g_plus, g_minus = self._g_split(mu, alpha_j)
lhs_total = lhs + g_minus
rhs_total = g_plus
if not bool(lhs_total):
lhs_total = lhs_total + self.error_bound
if not bool(rhs_total):
rhs_total = rhs_total + self.error_bound
with SignomialsEnabled():
cns = lhs_total <= rhs_total
self._append_symbolic_constraint(constraints, cns)
# Constraint family (2): product terms for lambda_0(beta) = 0 branch.
for beta in beta_terms:
if beta_info[beta]['lambda0'] > self.error_bound:
continue
lhs = 1.0
for j in beta_info[beta]['nz']:
if j == origin_idx:
continue
lam = float(beta_info[beta]['lambdas'][j])
if lam <= self.error_bound:
continue
lhs = lhs * (a[(beta, j)] / lam) ** lam
self._append_symbolic_constraint(constraints, lhs >= b[beta])
# Constraint families (3) and (4): positive/negative parts of G(mu)_beta.
for beta in beta_terms:
g_plus, g_minus = self._g_split(mu, beta)
if bool(g_plus):
constraints.append(b[beta] >= g_plus)
if bool(g_minus):
constraints.append(b[beta] >= g_minus)
def _solve_model(self, obj: Any, constraints: list, verbosity: int) -> None:
mdl = Model(obj, Bounded(ConstraintSet(constraints), upper=1 / self.error_bound))
try:
if self.use_local_solve:
try:
self.solution = mdl.localsolve(verbosity=verbosity)
except Exception as local_exc:
# Fallback to global GP solve when possible.
try:
self.solution = mdl.solve(verbosity=verbosity)
except Exception:
raise
else:
self.solution = mdl.solve(verbosity=verbosity)
except Exception as exc:
raise RuntimeError(f"SONC GP solve failed: {exc}") from exc
if self.solution is None:
raise RuntimeError("SONC GP solver returned no solution")
[docs]
def solve(self, verbosity: int | None = None) -> float:
"""Build and solve the constrained SONC geometric program."""
if verbosity is None:
verbosity = self.verbosity
try:
delta = self._build_delta_sets()
beta_terms = sorted(delta['=d'].union(delta['<d']), key=str)
supports, alpha, origin_idx, hull_computed = self._build_support_points()
# Filter beta_terms to exclude Newton polytope vertices (support exponents).
# Only do this when the convex hull was successfully computed; in the
# fallback case all exponents act as supports and filtering would wrongly
# empty the set.
if hull_computed:
support_tuples = set(supports)
beta_terms = [b_mon for b_mon in beta_terms
if tuple(self.prog.mono2ord_tuple(b_mon)) not in support_tuples]
if not beta_terms:
raise RuntimeError("Delta(G) is empty; SONC relaxation needs at least one interior beta term")
beta_info = self._build_beta_info(beta_terms, supports, origin_idx)
mu, a, b = self._initialize_variables(beta_terms, beta_info, origin_idx)
constraints = []
self._add_variable_bounds(constraints, mu, a, b)
self._build_constraints(constraints, mu, a, b, beta_terms, beta_info, alpha, origin_idx)
obj = self._build_objective(mu, a, b, beta_terms, beta_info, origin_idx)
if verbosity > 0:
print('obj=', obj)
print('-' * 30)
self._solve_model(obj, constraints, verbosity)
self.f_sonc_g = self.prog.objective.constant() - self.solution['cost']
return float(self.f_sonc_g)
except RuntimeError:
raise
except Exception as exc:
raise RuntimeError(f"SONC GP solve failed: {exc}") from exc