⚠️ Links are not working in the notebook. Please visit documentation for better experience.
Defining custom problems and optimizers#
A user-defined class#
A big advantage of using QHyper is the ability to run experiments from a configuration file. However, this only allows to use predefined problems, optimizers and solvers. In this notebook, we present a concise example illustrating how to define a custom problem, although the same principles apply to custom optimizers and solvers. We have chosen to highlight problem definition, as it is likely one of the most practical and valuable use cases for QHyper.
Note
Any custom Problem, Optimizer or Solver class should be implemented in the directory named custom or QHyper/custom. It is required that these classes inherit from their base classes and implement required methods. The new class will be available in configuration files by its attribute name if provided or by its class name.
Creating a custom problem#
Assume we want to minimize \(\underbrace{-2x_0 - 5x_1 - x_0x_1}_{cost function}\) subject to \(\underbrace{x_0 + x_1 = 1}_{constraint\ eq}\) and \(\underbrace{5x_0 + 2x_1 \leq 5}_{constraint\ le}\)
In QHyper, every problem needs to be a subclass of the Problem class.
In general, the cost function and every constraint should be expressed as dict-based Polynomials, but usually it is easier to initially express them in a more user-friendly format (such as SymPy syntax), and then convert it them into Polynomials. A Polynomial is comprised of a dictionary where the keys are tuples containing variables, and the values represent their coefficients.
To define the constraints, the Constraint class is used. Each constraint involves Polynomials on the left-hand side (lhs) and right-hand side (rhs), a comparison operator, and optional data such as a method for handling inequalities, a label, and a group identifier.
Note
QHyper always assumes that the objective is to minimize the cost function.
Using Dict syntax#
[1]:
import numpy as np
from QHyper.problems.base import Problem
from QHyper.constraint import Constraint, Operator, UNBALANCED_PENALIZATION
from QHyper.polynomial import Polynomial
class CustomProblem(Problem):
def __init__(self) -> None:
self.objective_function = self._create_objective_function()
self.constraints = self._create_constraints()
def _create_objective_function(self) -> Polynomial:
# Express the cost function as a dict. The keys are tuples containing variables, and the values represent the coefficients.
objective_function = {('x0',): -2.0, ('x1',): -5.0, ('x0', 'x1'): -1.0}
# Create a Polynomial based on the objective function.
return Polynomial(objective_function)
def _create_constraints(self) -> list[Constraint]:
# To add a new constraint, define the left-hand-side, and right-hand-side of the constraint.
# Also, specify the comparison operator and in the case of inequality opertor --- the method for handling the inequality.
constraints = [
Constraint(lhs={('x0',): 1.0, ('x1',): 1.0}, rhs={(): 1},
operator=Operator.EQ),
Constraint(lhs={('x0',): 5.0, ('x1',): 2.0}, rhs={(): 5},
operator=Operator.LE,
method_for_inequalities=UNBALANCED_PENALIZATION)
]
return constraints
def get_score(self, result: np.record, penalty: float = 0) -> float:
# This function is used by solvers to evaluate the quality of the result (business value).
# If the constraints are satisfied return the value of the objective function.
if result['x0'] + result['x1'] == 1 and 5 * result['x0'] + 2 * result['x1'] <= 5:
return -2 * result['x0'] - 5 * result['x1'] - result['x0'] * result['x1']
# Otherwise return some arbitrary penalty
return penalty
Using SymPy syntax#
[2]:
import sympy
import numpy as np
from QHyper.problems.base import Problem
from QHyper.constraint import Constraint, Operator, UNBALANCED_PENALIZATION
from QHyper.polynomial import Polynomial
from QHyper.parser import from_sympy
class CustomProblem(Problem):
def __init__(self) -> None:
# Define the necessary SymPy variables.
num_variables = 2
self.x = sympy.symbols(f'x0:{num_variables}')
self.objective_function = self._create_objective_function()
self.constraints = self._create_constraints()
def _create_objective_function(self) -> Polynomial:
# Define the cost function.
objective_function = -2 * self.x[0] - 5 * self.x[1] - self.x[0] * self.x[1]
# Return the cost function parsed into a Polynomial
return from_sympy(objective_function)
def _create_constraints(self) -> list[Constraint]:
# To add a new constraint, define the left-hand-side, and right-hand-side of the constraint.
# Also, specify the comparison operator and in the case of inequality opertor --- the method for handling the inequality.
return [
Constraint(
lhs=from_sympy(self.x[0] + self.x[1]),
rhs=1,
operator=Operator.EQ
),
Constraint(
lhs=from_sympy(5 * self.x[0] + 2 * self.x[1]),
rhs=5,
operator=Operator.LE,
method_for_inequalities=UNBALANCED_PENALIZATION,
)
]
def get_score(self, result: np.record, penalty: float = 0) -> float:
# This function is used by solvers to evaluate the quality of the result (business value).
# If the constraints are satisfied return the value of the objective function.
if result['x0'] + result['x1'] == 1 and 5 * result['x0'] + 2 * result['x1'] <= 5:
return -2 * result['x0'] - 5 * result['x1'] - result['x0'] * result['x1']
# Otherwise return some arbitrary penalty
return penalty
Note
For bigger problem instances, SymPy syntax is significantly slower than the Dict syntax.
To explore solvers for tackling this problem, check out this tutorial.
Creating a custom optimizer#
In order to define a custom optimizer, it is necessary to inherit from the Optimizer base class and implement the minimize method.
[3]:
import numpy as np
from typing import Callable
from QHyper.optimizers.base import (
Optimizer, OptimizationResult, OptimizerError, OptimizationParameter)
class CustomOptimizer(Optimizer):
def minimize(
self,
func: Callable[[list[float]], OptimizationResult],
init: OptimizationParameter | None,
steps: int = 10,
step_size: float = 1.,
) -> OptimizationResult:
# Check if initial parameters are valid
if init is None:
raise OptimizerError("Initial point must be provided.")
init.assert_init()
# Initialize current parameter and its function value
current_param = init.init[0]
current_value = func(current_param)
history = []
for _ in range(steps):
# Propose a new candidate by random perturbation
candidate_param = current_param + np.random.uniform(-step_size, step_size)
candidate_value = func(candidate_param)
# Accept the candidate if it improves the objective
if candidate_value < current_value:
current_param, current_value = candidate_param, candidate_value
# Record the result of this optimization step
current_optimization_result = OptimizationResult(value=candidate_value,
params=[candidate_param])
history.append(current_optimization_result)
# Return the best objective function value found after optimization
# as well as the corresponding parameter and the history of optimization trials
return OptimizationResult(value=current_value,
params=[current_param],
history=[history])
The following example demonstrates how to use the optimizer with a simple function f as the objective.
[4]:
def f(x):
return (x - 2)**2
custom_optimizer = CustomOptimizer()
init = OptimizationParameter(init=[0])
custom_optimizer.minimize(f, init)
[4]:
OptimizationResult(value=0.012911237077660507, params=[1.8863723753761414], history=[[OptimizationResult(value=5.80830991335693, params=[-0.410043550095502], history=[]), OptimizationResult(value=5.255084670119016, params=[-0.29239714493780866], history=[]), OptimizationResult(value=2.5763099383389783, params=[0.39491123661680994], history=[]), OptimizationResult(value=3.976884436512944, params=[0.005787263977851342], history=[]), OptimizationResult(value=0.4254175872601739, params=[1.3477595633049313], history=[]), OptimizationResult(value=1.3004339816666544, params=[0.8596342772309165], history=[]), OptimizationResult(value=0.022387632127825227, params=[1.8503750283949059], history=[]), OptimizationResult(value=0.28655020941040277, params=[1.4646961522551862], history=[]), OptimizationResult(value=0.012911237077660507, params=[1.8863723753761414], history=[]), OptimizationResult(value=0.025056148831267524, params=[2.15829134161813], history=[])]])
Another example of a straightforward implementation of a custom optimizer is a Dummy optimizer which just evaluates the function.