Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tensor Train Optimiser #31

Merged
merged 30 commits into from
May 1, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3ccd954
[feature] new tensor train optimiser
VolodyaCO Dec 29, 2022
6f16e67
[fix] bounds should always be of type float
VolodyaCO Dec 29, 2022
0564786
[feature] enabling several rounds of optimisation
VolodyaCO Dec 29, 2022
7506f1c
[docs] adding docs to the functions
VolodyaCO Jan 2, 2023
35416b5
[style] isorting
VolodyaCO Jan 2, 2023
1db09f0
[test] testing ttopt
VolodyaCO Jan 2, 2023
bcfb35d
[feature] inheriting attributes of cost function
VolodyaCO Jan 2, 2023
2167b65
[style] isorting
VolodyaCO Jan 2, 2023
42f8b88
[fix] types of _minimize fixed
VolodyaCO Jan 2, 2023
b92651e
[feature] dependencies
VolodyaCO Jan 2, 2023
a085a35
[fix] specifying the exception to be caught
VolodyaCO Jan 2, 2023
b039326
[feature] all includes ttopt
VolodyaCO Jan 2, 2023
0b945fb
[feature] new tensor train optimiser
VolodyaCO Dec 29, 2022
533c10a
[feature] enabling several rounds of optimisation
VolodyaCO Dec 29, 2022
72c4a3d
[docs] adding docs to the functions
VolodyaCO Jan 2, 2023
b5c5845
[style] isorting
VolodyaCO Jan 2, 2023
094154b
[test] testing ttopt
VolodyaCO Jan 2, 2023
4bc779a
[feature] inheriting attributes of cost function
VolodyaCO Jan 2, 2023
1297210
[style] isorting
VolodyaCO Jan 2, 2023
24f9dc1
[fix] types of _minimize fixed
VolodyaCO Jan 2, 2023
9afefd8
[feature] dependencies
VolodyaCO Jan 2, 2023
23b7541
[fix] specifying the exception to be caught
VolodyaCO Jan 2, 2023
a9d0aca
[feature] all includes ttopt
VolodyaCO Jan 2, 2023
a45d086
fix: ignore typing in ttopt
Jan 13, 2023
8796e9c
fix: add teneva to requirements for ttopt
Jan 13, 2023
2796ee9
Update src/orquestra/opt/optimizers/tensor_train_optimizer.py
VolodyaCO Jan 16, 2023
a58dcbe
Merge branch 'feature/volodyaco/ttopt' of https://github.com/zapataco…
VolodyaCO May 1, 2023
e22e305
[feature] bounds moved to api
VolodyaCO May 1, 2023
b8a73bd
[feature] metaclass to check membership of instances against _CostFun…
VolodyaCO May 1, 2023
18d3a12
[feature] increasing grid resolution for passing rosenbrock test
VolodyaCO May 1, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ module = [
'cma.*',
'networkx.*',
'skquant.*',
'ttopt.*',
]
ignore_missing_imports = true

Expand Down
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@ qiskit =
qubo =
dimod>=0.9.11
cvxpy~=1.1.11
ttopt =
ttopt~=0.5.0
teneva
all =
orquestra-opt[cma]
orquestra-opt[qiskit]
orquestra-opt[qubo]
orquestra-opt[scikit-quant]
orquestra-opt[ttopt]
dev =
orquestra-python-dev
orquestra-opt[all]
4 changes: 4 additions & 0 deletions src/orquestra/opt/optimizers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
from .scikit_quant_optimizer import ScikitQuantOptimizer
except ModuleNotFoundError:
pass
try:
from .tensor_train_optimizer import TensorTrainOptimizer
except ModuleNotFoundError:
pass
from .scipy_optimizer import ScipyOptimizer
from .search_points_optimizer import SearchPointsOptimizer
from .simple_gradient_descent import SimpleGradientDescent
264 changes: 264 additions & 0 deletions src/orquestra/opt/optimizers/tensor_train_optimizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
from typing import Callable, List, Sequence, Tuple, Union

import numpy as np
from scipy.optimize import OptimizeResult
from ttopt import TTOpt

from orquestra.opt.api.functions import CallableWithGradient
from orquestra.opt.optimizers.pso.continuous_pso_optimizer import (
Bounds, # TODO: where should these Bounds live?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these bounds specific to the pso optimizer? if so then I think they're living in the correct place, we might just want to change the name to PSOBounds or something like that. Either way, we should take care of this to avoid having TODO's in our product code. 😆

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think they are also used here in TTOpt, so I'd think this Bounds class should be in a higher level of the directory tree, but am unsure where they should be.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the bounds type could live in the __init__.py? Seems like a decent place for it.

)
from orquestra.opt.optimizers.pso.continuous_pso_optimizer import _get_bounds_like_array
AthenaCaesura marked this conversation as resolved.
Show resolved Hide resolved

from ..api import CostFunction, Optimizer, construct_history_info, optimization_result
from ..history.recorder import RecorderFactory
from ..history.recorder import recorder as _recorder


def _get_n_evaluations_per_candidate(
evaluation_budget: int, n_rounds: int, n_candidates_per_round: int
) -> List[int]:
"""
Computes how many evaluations correspond to each candidate in the fairest possible
way given an evaluation budget. The first round always includes only one candidate,
which is the initial optimisation. The remaining rounds include
`n_candidates_per_round` candidates.

Args:
evaluation_budget: Total number of evaluations available for all the
candidates.
n_rounds: Number of rounds for the optimization.
n_candidates_per_round: Number of candidates to evaluate in each round.

Returns:
A list of length (n_rounds - 1) * n_candidates_per_round + 1, with the number of
evaluations for each candidate.
"""
total_candidates = (n_rounds - 1) * n_candidates_per_round + 1
result = [
int(x)
for x in np.diff(
np.round(np.linspace(0, evaluation_budget, total_candidates + 1))
)
]
if 0 in result:
raise ValueError(
f"Cannot allocate {evaluation_budget} evaluations to {total_candidates}"
"candidates, because at least one candidate would get 0 evaluations. "
f"Assigned valuations are {result}"
)
return result


def _get_tighter_bounds(
candidate: np.ndarray,
bounds: Union[Tuple[float, float], Tuple[np.ndarray, np.ndarray]],
) -> Tuple[np.ndarray, np.ndarray]:
"""
Given a candidate and the bounds it lives in, returns a tighter bounds for the
candidate based on how close it is to the boundary of the hyperbox defined by the
bounds, and based on the scale of the bounds (i.e. the distance between the lower
and upper bounds).

Args:
candidate: An array of parameters.
bounds: Either a tuple of floats (lower_bound, upper_bound) or a tuple of arrays
(lower_bound, upper_bound), where lower_bound and upper_bound are arrays of
the same shape as candidate.

Returns:
A tuple of arrays (lower_bound, upper_bound), where lower_bound and upper_bound
are arrays of the same shape as candidate.
"""
lower_bound, upper_bound = bounds
dist_to_lower_bound = candidate - lower_bound
dist_to_upper_bound = upper_bound - candidate
bounds_scale = (upper_bound - lower_bound) / 4
if isinstance(bounds_scale, float):
bounds_scale = np.ones_like(candidate) * bounds_scale
closest_distances = np.max(
np.vstack((dist_to_lower_bound, dist_to_upper_bound)), axis=0
)
distances = np.min(np.vstack((closest_distances, bounds_scale)), axis=0)
return candidate - distances, candidate + distances


class _CostFunctionWithBestCandidates:
def __init__(
self,
cost_function: CostFunction,
n_candidates: int,
bounds: Union[Tuple[float, float], Tuple[np.ndarray, np.ndarray]],
):
"""
A wrapper for the cost function, which will keep information about the best
candidates found so far.

Args:
cost_function: A cost function.
n_candidates: Number of candidates to keep track of.
bounds: Either a tuple of floats (lower_bound, upper_bound) or a tuple of
arrays (lower_bound, upper_bound), where lower_bound and upper_bound are
arrays of the same shape.
"""
self.cost_function = cost_function
# self should have access to cost_function attributes:
for attr in dir(cost_function):
if not attr.startswith("__"):
setattr(self, attr, getattr(cost_function, attr))
self.candidates: List[dict] = [{}] * n_candidates
self.candidates_hashes = [0] * n_candidates
self.bounds = bounds
self.nfev = 0
self.candidate_index = 0
self.first_exploration = True

def __call__(self, parameters: np.ndarray) -> float:
cost = self.cost_function(parameters)
self.nfev += 1
if self.first_exploration:
for i, candidate in enumerate(self.candidates):
if not candidate or cost <= candidate["cost"]:
parameters_hash = hash(parameters.tobytes())
if parameters_hash in self.candidates_hashes:
self.candidates[i]["bounds"] = _get_tighter_bounds(
parameters, self.bounds
)
else:
self.candidates.insert(
i,
{
"cost": cost,
"parameters": parameters,
"bounds": _get_tighter_bounds(parameters, self.bounds),
},
)
self.candidates.pop()
self.candidates_hashes.insert(i, parameters_hash)
self.candidates_hashes.pop()
break
return cost
candidate = self.candidates[self.candidate_index]
if not candidate or cost <= candidate["cost"]:
parameters_hash = hash(parameters.tobytes())
if parameters_hash == self.candidates_hashes[self.candidate_index]:
self.candidates[self.candidate_index]["bounds"] = _get_tighter_bounds(
parameters, self.bounds
)
return cost
self.candidates[self.candidate_index] = {
"cost": cost,
"parameters": parameters,
"bounds": _get_tighter_bounds(parameters, self.bounds),
}
self.candidates_hashes[self.candidate_index] = parameters_hash
return cost

def gather_best_candidates(self) -> List[dict]:
"""
Creates a list of the best candidates found so far.
"""
return [c for c in self.candidates if "parameters" in c.keys()]


class TensorTrainOptimizer(Optimizer):
def __init__(
self,
n_grid_points: Union[int, Sequence[int]],
n_evaluations: int,
bounds: Bounds,
maximum_tensor_train_rank: int = 4,
n_rounds: int = 1,
maximum_number_of_candidates: int = 5,
recorder: RecorderFactory = _recorder,
):
"""
Constructor of a TensorTrainOptimizer. This optimizer uses a tensor-train
representation of the cost function to find the minimum of the cost function.
It uses the `ttopt` library to do so. This optimizer finds the minimum of the
cost function at points given in a grid. This class extends this concept by
being able to pick the best candidate solutions in the grid, and then one
simply iterates over those candidates on finer grids centered around each
candidate to replace each candidate with a better one found in the finer grid.

Args:
n_grid_points: Number of grid points to use in each dimension.
n_evaluations: Cost function evaluations budget.
bounds: Lower and upper bounds for each parameter.
maximum_tensor_train_rank: Maximum bond dimension for the tensor train.
n_rounds: Number of optimisation rounds. If 1, then only the initial grid
is used. If 2 or more, then the best candidates found in the initial
grid are used to create finer grids. Each of these finer grids will
get finer and finer depending on the requested number of rounds.
maximum_number_of_candidates: Number of candidates to keep track of.
recorder Recorder factory for keeping history of calls to the objective
function.
"""
super().__init__(recorder=recorder)
self.n_grid_points = n_grid_points
self.n_evaluations = n_evaluations
self.bounds = _get_bounds_like_array(bounds)
self.maximum_tensor_train_rank = maximum_tensor_train_rank
self.n_rounds = n_rounds
self.maximum_number_of_candidates = maximum_number_of_candidates
self.evaluations_per_candidate = _get_n_evaluations_per_candidate(
n_evaluations, n_rounds, maximum_number_of_candidates
)

def _preprocess_cost_function(
self, cost_function: CostFunction
) -> _CostFunctionWithBestCandidates:
"""
Wraps the cost function in a CostFunctionWithBestCandidates.
"""
return _CostFunctionWithBestCandidates(
cost_function, self.maximum_number_of_candidates, self.bounds
)

def _minimize(
self,
cost_function: Union[CallableWithGradient, Callable],
initial_params: np.ndarray,
keep_history: bool = False,
) -> OptimizeResult:
assert isinstance(cost_function, _CostFunctionWithBestCandidates)
n_dim = initial_params.size
evaluations_per_candidate_iterator = iter(self.evaluations_per_candidate)
for i in range(self.n_rounds):
best_candidates = cost_function.gather_best_candidates()
if len(best_candidates) == 0:
ttopt = TTOpt(
f=cost_function,
d=n_dim,
a=self.bounds[0],
b=self.bounds[1],
n=self.n_grid_points,
evals=next(evaluations_per_candidate_iterator),
is_vect=False,
is_func=True,
)
ttopt.minimize(self.maximum_tensor_train_rank)
else:
cost_function.first_exploration = False
for idx, candidate in enumerate(best_candidates):
cost_function.candidate_index = idx
cost_function.bounds = candidate["bounds"]
ttopt = TTOpt(
f=cost_function,
d=n_dim,
a=candidate["bounds"][0],
b=candidate["bounds"][1],
n=self.n_grid_points,
evals=next(evaluations_per_candidate_iterator),
is_vect=False,
is_func=True,
)
ttopt.minimize(self.maximum_tensor_train_rank)
best_candidate = cost_function.gather_best_candidates()[0]
return optimization_result(
opt_value=best_candidate["cost"],
opt_params=best_candidate["parameters"],
nit=None,
nfev=cost_function.nfev,
**construct_history_info(cost_function, keep_history), # type: ignore
)
26 changes: 26 additions & 0 deletions tests/orquestra/opt/optimizers/tensor_train_optimizer_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pytest

from orquestra.opt.api.optimizer_test import OPTIMIZER_CONTRACTS
from orquestra.opt.optimizers.tensor_train_optimizer import TensorTrainOptimizer


@pytest.fixture(
params=[
{
"n_grid_points": 21,
"n_evaluations": 10000,
"bounds": (-2.0, 2.0),
"maximum_tensor_train_rank": 4,
"n_rounds": 3,
"maximum_number_of_candidates": 3,
}
]
)
def optimizer(request):
return TensorTrainOptimizer(**request.param)


class TestTensorTrainOptimizer:
AthenaCaesura marked this conversation as resolved.
Show resolved Hide resolved
@pytest.mark.parametrize("contract", OPTIMIZER_CONTRACTS)
def test_optimizer_satisfies_contracts(self, contract, optimizer):
assert contract(optimizer)