# (C) Quantum Computing Inc., 2024.
"""
Four useful classes are provided in this module.
ConstraintsMixin
Converts equality constraints to penalties. Depends on the value provided
for penalty_multiplier.
InequalitiesMixin
Allows inequality constraints, converting to equality constraints before
penalties by adding slack variables.
ConstraintModel
An example implementation of the ConstraintsMixin.
InequalityConstraintModel
An example implementation of the InequalitiesMixin.
>>> lhs = np.array([[1, 1],
... [2, 2]])
>>> rhs = np.array([1, 1])
>>> senses = ["LE", "GE"]
>>> model = InequalityConstraintModel()
>>> model.constraints = lhs, rhs
>>> model.senses = senses
>>> A, b = model.constraints
>>> A
array([[ 1., 1., 1., 0.],
[ 2., 2., 0., -1.]])
>>> model.penalty_multiplier = 1.0
>>> model.checkPenalty(np.array([1, 0, 0, 1]))
0.0
>>> model.checkPenalty(np.array([1, 1, 0, 0]))
10.0
"""
from typing import (List, Tuple)
import numpy as np
from eqc_models.base.base import EqcModel
[docs]
class ConstraintsMixIn:
"""
This mixin class contains methods and attributes which transform
linear constraints into penalties.
"""
lhs = None
rhs = None
# alpha is the internal name for the penalty multiplier
# defaulting to 1 as a user experience enhancement
# while forcing the user to choose a value helps to
# remind of the need, it is not friendly to get a None
# type error when an attempt to use it is made.
alpha = 1
linear_objective = None
quad_objective = None
@property
def penalties(self) -> Tuple[np.ndarray, np.ndarray]:
""" Returns two numpy arrays, one linear and one quadratic pieces of an operator """
lhs, rhs = self.constraints
if lhs is None or rhs is None:
raise ValueError("Constraints lhs and/or rhs are undefined. " +
"Both must be instantiated numpy arrays.")
Pq = lhs.T @ lhs
Pl = -2 * rhs.T @ lhs
return Pl.T, Pq
@property
def penalty_multiplier(self) -> float:
return self.alpha
@penalty_multiplier.setter
def penalty_multiplier(self, value: float):
self.alpha = value
@property
def constraints(self):
return self.lhs, self.rhs
@constraints.setter
def constraints(self, value: Tuple[np.ndarray, np.ndarray]):
self.lhs, self.rhs = value
@property
def offset(self) -> float:
""" Calculate the offset due to the conversion of constraints to penalties """
lhs, rhs = self.constraints
return np.squeeze(rhs.T@rhs)
[docs]
def evaluate(self, solution : np.ndarray, alpha : float = None, includeoffset:bool=False):
"""
Compute the objective value plus penalties for the given solution. Including
the offset will ensure the penalty contribution is non-negative.
Parameters
----------
solution : np.array
The solution vector for the problem.
alpha : float
Penalty multiplier, optional. This can be used to test different
multipliers for determination of sufficiently large values.
"""
if alpha is None:
alpha = self.penalty_multiplier
penalty = self.evaluatePenalties(solution)
penalty *= alpha
if includeoffset:
penalty += alpha * self.offset
return penalty + self.evaluateObjective(solution)
[docs]
def evaluatePenalties(self, solution) -> float:
"""
Evaluate penalty function without alpha or offset
Parameters
----------
solution : np.array
The solution vector for the problem.
"""
Pl, Pq = self.penalties
qpart = solution.T@Pq@solution
lpart = Pl.T@solution
ttlpart = qpart + lpart
return ttlpart
[docs]
def checkPenalty(self, solution : np.ndarray):
"""
Get the penalty of the solution.
Parameters
----------
solution : np.array
The solution vector for the problem.
"""
penalty = self.evaluatePenalties(solution)
penalty += self.penalty_multiplier * self.offset
assert penalty >= 0, "Inconsistent model, penalty cannot be less than 0."
return penalty
[docs]
class InequalitiesMixin:
"""
This mixin enables inequality constraints by automatically
generating slack variables for each inequality
This mixin adds a `senses` attribute which has a value for each
constraint. The values are one of 'EQ', 'LE' or 'GE' for equal
to, less than or equal to or greater than or equal to. The effect
of the value is to control whether a slack is added and what
the sign of the slack variable in the constraint is. Negative
is used for GE, positive is used for LE and all slack variables
get a coefficient magnitude of 1.
The constraints are modified on demand, so the class members,
`lhs` and `rhs` remain unmodified.
"""
_senses = None
@property
def senses(self) -> List[str]:
""" Comparison operator by constraint """
return self._senses
@senses.setter
def senses(self, value : List[str]):
self._senses = value
@property
def num_slacks(self) -> int:
"""
The number of slack variables. Will match the number of inequality
constraints.
Returns
-------
number : int
"""
G = self.lhs
m = G.shape[0]
senses = self.senses
num_slacks = sum([0 if senses[i] == "EQ" else 1 for i in range(m)])
return num_slacks
@property
def constraints(self) -> Tuple[np.ndarray, np.ndarray]:
"""
Get the general form of the constraints, add slacks where needed
and return a standard, equality constraint form.
"""
G = self.lhs
h = self.rhs
senses = self.senses
m = G.shape[0]
n = G.shape[1]
num_slacks = self.num_slacks
# Adjusted dimensions for slack variables
slack_vars = np.zeros((m, num_slacks))
ii = 0
for i in range(m):
rule = senses[i]
if rule == "LE":
# Add slack variable for less than or equal constraint
slack_vars[i, ii] = 1
ii += 1
elif rule == "GE":
# Add negated slack variable for greater than or equal constraint
slack_vars[i, ii] = -1
ii += 1
A = np.hstack((G, slack_vars))
b = h
return A, b
@constraints.setter
def constraints(self, value : Tuple[np.ndarray, np.ndarray]):
if len(value) != 2:
raise ValueError("Constraints must be specified as a 2-tuple")
self.lhs, self.rhs = value
[docs]
class ConstraintModel(ConstraintsMixIn, EqcModel):
"""
Abstract class for representing linear constrained optimization problems as
EQC models.
"""
[docs]
class InequalityConstraintModel(InequalitiesMixin, ConstraintModel):
"""
Abstract class for a linear constrained optimization model with inequality constraints
"""
# class MIPBinaryModel(ConstraintModel):
#
# binary_only_variables = None
#
# @property
# def binaries(self) -> List:
# return self.binary_only_variables
#
# @binaries.setter
# def binaries(self, value : List) -> None:
# for item in value:
# assert int(item) == item, "Index of binary variable must be integer"
# self.binary_only_variables = value
#
# @property
# def penalties(self) -> Tuple[np.ndarray, np.ndarray]:
# # get the explicit constraint penalties
# Pl, Pq = super(MIPBinaryModel, self).penalties
# if self.binary_only_variables is not None:
# # add penalties which enforce the selection of 0 or 1 for each binar variable
# for idx in self.binaries:
# # add the values x(x-1)^2 -> x^3-2x^2+x
# indices = [[idx+1, idx+1, idx+1], [0, idx+1, idx+1], [0, 0, idx+1]]
# coefficients = [1, -2, 1]
#
# return Pl, Pq