Skip to content

Commit

Permalink
2020/december/hyperspheres as objective function2 (#204)
Browse files Browse the repository at this point in the history
* Started moving hyperspheres to a separate objective function

* Created a Hypersphere ObjectiveFunction and the tests are passing

* Linter

Co-authored-by: Adam Smiechowski <adsmiech@microsoft.com>
  • Loading branch information
byte-sculptor and Adam Smiechowski authored Dec 11, 2020
1 parent 6407de7 commit 8c3e91e
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 175 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import pandas as pd

from mlos.Optimizers.OptimizationProblem import OptimizationProblem
from mlos.Spaces import Hypergrid, Point


Expand All @@ -17,6 +18,7 @@ class ObjectiveFunctionBase(ABC):
@abstractmethod
def __init__(self, objective_function_config: Point, *args, **kwargs):
self.objective_function_config = objective_function_config
self._default_optimization_problem = None

@property
@abstractmethod
Expand All @@ -36,6 +38,14 @@ def output_space(self) -> Hypergrid:
"""
raise NotImplementedError

@property
def default_optimization_problem(self):
return self._default_optimization_problem

@default_optimization_problem.setter
def default_optimization_problem(self, value: OptimizationProblem):
self._default_optimization_problem = value

def evaluate_point(self, point: Point) -> Point:
# If evaluate_point is not implemented in the subclass, we can make it work like so:
#
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
#
from mlos.Spaces import CategoricalDimension, DiscreteDimension, Point, SimpleHypergrid
from mlos.Spaces.Configs import ComponentConfigStore
from mlos.OptimizerEvaluationTools.SyntheticFunctions.Flower import Flower
from mlos.OptimizerEvaluationTools.SyntheticFunctions.Hypersphere import Hypersphere
from mlos.OptimizerEvaluationTools.SyntheticFunctions.HypersphereConfigStore import hypersphere_config_store
from mlos.OptimizerEvaluationTools.SyntheticFunctions.NestedPolynomialObjective import NestedPolynomialObjective
from mlos.OptimizerEvaluationTools.SyntheticFunctions.PolynomialObjective import PolynomialObjective
from mlos.OptimizerEvaluationTools.SyntheticFunctions.ThreeLevelQuadratic import ThreeLevelQuadratic
from mlos.Spaces import CategoricalDimension, DiscreteDimension, Point, SimpleHypergrid
from mlos.Spaces.Configs import ComponentConfigStore

objective_function_config_store = ComponentConfigStore(
parameter_space=SimpleHypergrid(
Expand All @@ -18,6 +20,7 @@
NestedPolynomialObjective.__name__,
PolynomialObjective.__name__,
ThreeLevelQuadratic.__name__,
Hypersphere.__name__
])
]
).join(
Expand All @@ -35,6 +38,9 @@
on_external_dimension=CategoricalDimension(name="nested_function_implementation", values=[PolynomialObjective.__name__])
),
on_external_dimension=CategoricalDimension(name="implementation", values=[NestedPolynomialObjective.__name__])
).join(
subgrid=hypersphere_config_store.parameter_space,
on_external_dimension=CategoricalDimension(name="implementation", values=[Hypersphere.__name__])
),
default=Point(
implementation=PolynomialObjective.__name__,
Expand Down Expand Up @@ -156,3 +162,13 @@
)
)
)

for named_hypersphere_config in hypersphere_config_store.list_named_configs():
objective_function_config_store.add_config_by_name(
config_name=named_hypersphere_config.name,
config_point=Point(
implementation=Hypersphere.__name__,
hypersphere_config=named_hypersphere_config.config_point,
description=named_hypersphere_config.description
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from mlos.OptimizerEvaluationTools.ObjectiveFunctionBase import ObjectiveFunctionBase
from mlos.OptimizerEvaluationTools.ObjectiveFunctionConfigStore import objective_function_config_store
from mlos.OptimizerEvaluationTools.SyntheticFunctions.Flower import Flower
from mlos.OptimizerEvaluationTools.SyntheticFunctions.Hypersphere import Hypersphere
from mlos.OptimizerEvaluationTools.SyntheticFunctions.NestedPolynomialObjective import NestedPolynomialObjective
from mlos.OptimizerEvaluationTools.SyntheticFunctions.PolynomialObjective import PolynomialObjective
from mlos.OptimizerEvaluationTools.SyntheticFunctions.PolynomialObjectiveWrapper import PolynomialObjectiveWrapper
Expand Down Expand Up @@ -34,4 +35,7 @@ def create_objective_function(cls, objective_function_config: Point) -> Objectiv
if objective_function_config.implementation == NestedPolynomialObjective.__name__:
return NestedPolynomialObjective(objective_function_config.nested_polynomial_objective_config)

if objective_function_config.implementation == Hypersphere.__name__:
return Hypersphere(objective_function_config.hypersphere_config)

raise ValueError(f"Can't instantiate an objective function with the following implementation: {objective_function_config.implementation}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
#
import math

import numpy as np
import pandas as pd

from mlos.OptimizerEvaluationTools.ObjectiveFunctionBase import ObjectiveFunctionBase
from mlos.Optimizers.OptimizationProblem import Objective, OptimizationProblem
from mlos.Spaces import ContinuousDimension, Hypergrid, Point, SimpleHypergrid

class Hypersphere(ObjectiveFunctionBase):
"""Multi-objective function that converts spherical coordinates to cartesian ones.
The idea is that we want to find a pareto frontier that optimizes the cartesian coordinates
of points defined using random spherical coordinates.
By setting the radius of some of the points to the radius of the hypersphere, we guarantee
that they are non-dominated. Such points must appear on the pareto frontier, though it's
quite possible that other non-dominated points from the interior of the sphere could appear
as well. The intuition in 2D is that we can draw a secant between two neighboring pareto
efficient points on the perimeter. Any point that is between that secant and the perimeter
is not dominated and would thus be pareto efficient as well. (Actually even more points
are pareto efficient, but this subset is easiest to explain in text).
We want to use this objective function to test scenarios where:
1) all objectives are maximized,
2) all objectives are minimized,
3) some objectives are maximized and some are minimized.
We want to be able to do that for an arbitrary number of dimensions so as to extract
maximum coverage from this simple test.
How the function works?
-------------------
For N objectives we will specify the following parameters:
1. radius - distance of a point from origin.
2. theta0, theta1, ..., theta{i}, ..., theta{N-1} - angle between the radius
segment and the hyperplane containing unit vectors along y0, y1, ..., y{i-1}
And the following N objectives that are computed from parameters:
y0 = radius * cos(theta0)
y1 = radius * sin(theta0) * cos(theta1)
y2 = radius * sin(theta0) * sin(theta1) * cos(theta2)
y3 = radius * sin(theta0) * sin(theta1) * sin(theta2) * cos(theta3)
...
y{N-2} = radius * sin(theta0) * sin(theta1) * ... * sin(theta{N-2}) * cos(theta{N-1})
y{N-1} = radius * sin(theta0) * sin(theta1) * ... * sin(theta{N-2}) * sin(theta{N-1})
!!! sin instead of cos !!! ^
1) Maximizing all objectives.
To maximize all objectives we need them to be non-negative. In such as setup
all points with r == sphere_radius will be pareto efficient. And we can assert that
the computed pareto frontier contains them.
This can be guaranteed, by keeping all angles theta in the first quadrant (0 .. pi/2) since both sin and cos are
positive there. Thus their product will be too.
2) Minimizing all objectives.
Similarly, to minimize all objectives we need them to be non-positive. In such
a setup we know that all points with r == sphere_radius are pareto efficient and
we can assert that they are returned in the computation.
We observe that all objectives except for the last one contain any number of sin
factors and a single cosine factor. Cosine is guaranteed to be negative in the
second quadrant (pi/2 .. pi) and sine is guaranteed to be positive there.
So keeping all thetas in the range [pi/2 .. pi] makes all objectives negative
except for the last one (which we can simply flip manually).
3) Maximizing some objectives while minimizing others.
We can take advantage of the fact that every second objective has an odd number
of sin factors, whilst the rest has an even number (again, except for the last
one). So if we keep all sin factors negative, and all the cos factors positive, we
get a neat situation of alternating objectives' signs.
This is true in the fourth quadrant (3 * pi / 2 .. 2 * pi), where sin values are
negative, and cos values are positive.
The last objective - y{N-1} - will have N negative terms, so it will be positive if
(N % 2) == 0 and negative otherwise.
In other words:
if (N % 2) == 0:
maximize y{N-1}
else:
minimize y{N-1}
"""

def __init__(self, objective_function_config: Point = None):
ObjectiveFunctionBase.__init__(self, objective_function_config)

self.num_objectives = self.objective_function_config.num_objectives
self.radius = self.objective_function_config.radius
self.minimize = self.objective_function_config.minimize

# Let's figure out the quadrant and which objectives to minimize.
#
if self.minimize == "all":
# Let's keep angles in second quadrant.
#
self.theta_min = math.pi / 2
self.theta_max = math.pi
self.minimize_mask = [True for _ in range(self.num_objectives)]

elif self.minimize == "none":
# Let's keep all angles in the first quadrant.
#
self.theta_min = 0
self.theta_max = math.pi / 2
self.minimize_mask = [False for _ in range(self.num_objectives)]

elif self.objective_function_config.minimize == "some":
# Let's keep all angles in the fourth quadrant.
#
self.theta_min = 1.5 * math.pi
self.theta_max = 2 * math.pi

# Let's minimize odd ones, that way the y{N-1} doesn't require a sign flip.
#
self.minimize_mask = [(i % 2) == 1 for i in range(self.num_objectives)]

else:
assert False

# Let's put together the optimization problem.
#
parameter_dimensions = [ContinuousDimension(name="radius", min=0, max=self.radius)]
for i in range(self.num_objectives):
parameter_dimensions.append(ContinuousDimension(name=f"theta{i}", min=self.theta_min, max=self.theta_max))

self._parameter_space = SimpleHypergrid(
name='spherical_coordinates',
dimensions=parameter_dimensions
)

objective_dimensions = []
for i, minimize in enumerate(self.minimize_mask):
if minimize:
objective_dimensions.append(ContinuousDimension(name=f"y{i}", min=-self.radius, max=0))
else:
objective_dimensions.append(ContinuousDimension(name=f"y{i}", min=0, max=self.radius))

self._objective_space = SimpleHypergrid(
name='rectangular_coordinates',
dimensions=objective_dimensions
)

# TODO: add this to the ObjectiveFunctionBase interface.
#
self.default_optimization_problem = OptimizationProblem(
parameter_space=self._parameter_space,
objective_space=self._objective_space,
objectives=[
Objective(name=f'y{i}', minimize=minimize_objective)
for i, minimize_objective
in enumerate(self.minimize_mask)
]
)

@property
def parameter_space(self) -> Hypergrid:
return self._parameter_space

@property
def output_space(self) -> Hypergrid:
return self._objective_space

def evaluate_dataframe(self, dataframe: pd.DataFrame):
# We can compute our objectives more efficiently, by maintaining a prefix of r * sin(theta0) * ... * sin(theta{i-1})
#
prefix = dataframe['radius']
objectives_df = pd.DataFrame()
for i in range(self.num_objectives - 1):
objectives_df[f'y{i}'] = prefix * np.cos(dataframe[f'theta{i}'])
prefix = prefix * np.sin(dataframe[f'theta{i}'])

# Conveniently, by the time the loop exits, the prefix is the value of our last objective.
#
if self.minimize == "all":
# Must flip the prefix first, since there was no negative cosine to do it for us.
#
objectives_df[f'y{self.num_objectives - 1}'] = -prefix
else:
objectives_df[f'y{self.num_objectives - 1}'] = prefix

return objectives_df

def get_context(self) -> Point:
""" Returns a context value for this objective function.
If the context changes on every invokation, this should return the latest one.
:return:
"""
return Point(radius=self.radius)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
#
from mlos.Spaces import CategoricalDimension, ContinuousDimension, DiscreteDimension, Point, SimpleHypergrid
from mlos.Spaces.Configs import ComponentConfigStore

hypersphere_config_store = ComponentConfigStore(
parameter_space=SimpleHypergrid(
name="hypersphere_config",
dimensions=[
DiscreteDimension(name="num_objectives", min=1, max=100),
CategoricalDimension(name="minimize", values=["all", "none", "some"]),
ContinuousDimension(name="radius", min=0, max=100, include_min=False)
]
),
default=Point(
num_objectives=3,
minimize="all",
radius=10
)
)

for num_objectives in [2, 10]:
for minimize in ["all", "none", "some"]:
hypersphere_config_store.add_config_by_name(
config_name=f"{num_objectives}d_hypersphere_minimize_{minimize}",
config_point=Point(
num_objectives=num_objectives,
minimize=minimize,
radius=10
),
description=f"An objective function with {num_objectives + 1} parameters and {num_objectives} objectives to maximize."
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
#
import pytest
import warnings

from mlos.OptimizerEvaluationTools.ObjectiveFunctionFactory import ObjectiveFunctionFactory, objective_function_config_store
Expand All @@ -16,24 +17,18 @@ def classSetUp(cls):
mlos.global_values.declare_singletons()
warnings.simplefilter("error", category=FutureWarning)

def test_named_configs(self):

named_configs = objective_function_config_store.list_named_configs()

objective_function_configs_to_test = [
named_config.config_point for named_config in named_configs
]

for objective_function_config in objective_function_configs_to_test:
print(objective_function_config.to_json(indent=2))
objective_function = ObjectiveFunctionFactory.create_objective_function(objective_function_config=objective_function_config)
default_polynomials_domain = objective_function.parameter_space
for _ in range(100):
random_point = default_polynomials_domain.random()
value = objective_function.evaluate_point(random_point)
assert value in objective_function.output_space

for i in range(1, 100):
random_dataframe = default_polynomials_domain.random_dataframe(num_samples=i)
values_df = objective_function.evaluate_dataframe(random_dataframe)
assert values_df.index.equals(random_dataframe.index)
@pytest.mark.parametrize("config_name", [config.name for config in objective_function_config_store.list_named_configs()])
def test_named_configs(self, config_name):
objective_function_config = objective_function_config_store.get_config_by_name(config_name)
print(objective_function_config.to_json(indent=2))
objective_function = ObjectiveFunctionFactory.create_objective_function(objective_function_config=objective_function_config)

for _ in range(100):
random_point = objective_function.parameter_space.random()
value = objective_function.evaluate_point(random_point)
assert value in objective_function.output_space

for i in range(1, 100):
random_dataframe = objective_function.parameter_space.random_dataframe(num_samples=i)
values_df = objective_function.evaluate_dataframe(random_dataframe)
assert values_df.index.equals(random_dataframe.index)
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#
import concurrent.futures
import json
from multiprocessing import cpu_count
import os
import pickle

Expand Down Expand Up @@ -176,7 +177,7 @@ def test_named_configs(self):

num_tests = max(num_optimizer_configs, num_objective_function_configs)

with traced(scope_name="parallel_tests"), concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
with traced(scope_name="parallel_tests"), concurrent.futures.ProcessPoolExecutor(max_workers=cpu_count()) as executor:
outstanding_futures = set()

for i in range(num_tests):
Expand Down
Loading

0 comments on commit 8c3e91e

Please sign in to comment.