diff --git a/docs/notebooks/multi_obj_with_constraints.ipynb b/docs/notebooks/multi_obj_with_constraints.ipynb index fdfb0fd..2e3c96b 100644 --- a/docs/notebooks/multi_obj_with_constraints.ipynb +++ b/docs/notebooks/multi_obj_with_constraints.ipynb @@ -14,7 +14,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "4c8da843-670c-4f07-bd66-471ec19d3601", "metadata": { "tags": [] @@ -38,7 +38,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "311c88fa-f757-44f3-8ae5-555f715fc1b4", "metadata": {}, "outputs": [], @@ -77,31 +77,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "c0dc70de-f14f-42a4-9202-0e4777d33bec", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter WLSAccessID\n", - "Set parameter WLSSecret\n", - "Set parameter LicenseID to value 2512524\n", - "Academic license 2512524 - for non-commercial use only - registered to t.___@imperial.ac.uk\n" - ] - }, - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# get optimization model\n", "model_gur = problem_config.get_gurobi_model_core()\n", @@ -124,7 +103,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "61a8e860-92ee-4535-ad73-9b2acf132ad8", "metadata": {}, "outputs": [], @@ -142,7 +121,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "15bc6c5d-011f-4c57-8af0-4da9704af41e", "metadata": {}, "outputs": [], @@ -154,120 +133,20 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "e987e159-d855-4476-b7f8-3a041ab45d5f", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter MIPGap to value 0\n", - "Set parameter NonConvex to value 2\n", - "Gurobi Optimizer version 10.0.3 build v10.0.3rc0 (win64)\n", - "\n", - "CPU model: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 4 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Academic license 2512524 - for non-commercial use only - registered to t.___@imperial.ac.uk\n", - "Optimize a model with 3172 rows, 1828 columns and 11438 nonzeros\n", - "Model fingerprint: 0xa6d043a3\n", - "Model has 100 SOS constraints\n", - "Variable types: 1803 continuous, 25 integer (24 binary)\n", - "Coefficient statistics:\n", - " Matrix range [5e-08, 2e+04]\n", - " Objective range [1e+00, 2e+00]\n", - " Bounds range [1e+00, 6e+00]\n", - " RHS range [2e-04, 8e+03]\n", - "Presolve removed 367 rows and 248 columns\n", - "Presolve time: 0.12s\n", - "Presolved: 2805 rows, 1580 columns, 9988 nonzeros\n", - "Presolved model has 94 SOS constraint(s)\n", - "Variable types: 1557 continuous, 23 integer (23 binary)\n", - "\n", - "Root relaxation: unbounded, 647 iterations, 0.04 seconds (0.02 work units)\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 postponed 0 - - - - 0s\n", - " 0 0 postponed 0 - - - - 0s\n", - " 0 2 postponed 0 - - - - 0s\n", - "H 32 25 -0.2625355 - - 421 1s\n", - "H 34 25 -0.3766411 - - 397 1s\n", - "\n", - "Explored 145 nodes (22173 simplex iterations) in 1.69 seconds (1.08 work units)\n", - "Thread count was 8 (of 8 available processors)\n", - "\n", - "Solution count 2: -0.376641 -0.262536 \n", - "\n", - "Optimal solution found (tolerance 0.00e+00)\n", - "Best objective -3.766411305508e-01, best bound -3.766411305508e-01, gap 0.0000%\n" - ] - } - ], + "outputs": [], "source": [ "res_gur = opt_gur.solve(enting, model_core=model_gur)" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "4ae2f026-dded-4376-866b-6ca34adc5bf9", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter MIPGap to value 0\n", - "Set parameter NonConvex to value 2\n", - "Gurobi Optimizer version 10.0.3 build v10.0.3rc0 (win64)\n", - "\n", - "CPU model: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, instruction set [SSE2|AVX|AVX2|AVX512]\n", - "Thread count: 4 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Academic license 2512524 - for non-commercial use only - registered to t.___@imperial.ac.uk\n", - "Optimize a model with 3172 rows, 1828 columns and 11438 nonzeros\n", - "Model fingerprint: 0x7f777cf7\n", - "Model has 100 SOS constraints\n", - "Variable types: 1803 continuous, 25 integer (24 binary)\n", - "Coefficient statistics:\n", - " Matrix range [5e-08, 2e+04]\n", - " Objective range [1e+00, 2e+00]\n", - " Bounds range [1e+00, 6e+00]\n", - " RHS range [2e-04, 8e+03]\n", - "Presolve removed 367 rows and 248 columns\n", - "Presolve time: 0.07s\n", - "Presolved: 2805 rows, 1580 columns, 9984 nonzeros\n", - "Presolved model has 94 SOS constraint(s)\n", - "Variable types: 1557 continuous, 23 integer (23 binary)\n", - "\n", - "Root relaxation: unbounded, 862 iterations, 0.06 seconds (0.05 work units)\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 postponed 0 - - - - 0s\n", - " 0 0 postponed 0 - - - - 0s\n", - " 0 2 postponed 0 - - - - 0s\n", - "H 35 31 -0.1298538 - - 261 0s\n", - "H 42 31 -0.3409819 - - 218 0s\n", - "H 72 30 -0.3462866 - - 138 1s\n", - "H 110 18 -0.3557482 - - 138 1s\n", - "H 125 16 -0.4010824 - - 146 1s\n", - "H 144 7 -0.4035719 -0.79918 98.0% 144 1s\n", - "\n", - "Explored 167 nodes (25805 simplex iterations) in 1.47 seconds (1.03 work units)\n", - "Thread count was 8 (of 8 available processors)\n", - "\n", - "Solution count 6: -0.403572 -0.401082 -0.355748 ... -0.129854\n", - "\n", - "Optimal solution found (tolerance 0.00e+00)\n", - "Best objective -4.035719394214e-01, best bound -4.035719394214e-01, gap 0.0000%\n" - ] - } - ], + "outputs": [], "source": [ "# Build GurobiOptimizer object and solve optimization problem\n", "params_gurobi = {\"MIPGap\": 0}\n", @@ -279,29 +158,6 @@ "assert round(x_opt, 5) == round(y_opt, 5) and round(y_opt, 5) == round(z_opt, 5)" ] }, - { - "cell_type": "code", - "execution_count": 14, - "id": "f6e5a95f", - "metadata": {}, - "outputs": [ - { - "ename": "IndexError", - "evalue": "list index out of range", - "output_type": "error", - "traceback": [ - "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[1;31mIndexError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[1;32mIn[14], line 3\u001b[0m\n\u001b[0;32m 1\u001b[0m al \u001b[38;5;241m=\u001b[39m opt_gur\u001b[38;5;241m.\u001b[39m_active_leaves\n\u001b[0;32m 2\u001b[0m \u001b[38;5;66;03m# list[tuple[int, str]]\u001b[39;00m\n\u001b[1;32m----> 3\u001b[0m \u001b[43mal\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;241;43m2\u001b[39;49m\u001b[43m]\u001b[49m\n", - "\u001b[1;31mIndexError\u001b[0m: list index out of range" - ] - } - ], - "source": [ - "al = opt_gur._active_leaves\n", - "# list[tuple[int, str]]\n" - ] - }, { "cell_type": "markdown", "id": "24b3d335-d601-41c1-ad46-d80949c0cfcc", @@ -346,14 +202,6 @@ "source": [ "Note that no model update is required in the Pyomo version in contrast to the Gurobi variant" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b6694759-9337-45b1-a39e-b608281cd1da", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/entmoot/models/enting.py b/entmoot/models/enting.py index e6adc70..f44c278 100644 --- a/entmoot/models/enting.py +++ b/entmoot/models/enting.py @@ -88,19 +88,16 @@ def fit(self, X: list | np.ndarray, y: np.ndarray) -> None: X = self._problem_config.encode(X) # check dims of X and y - if X.ndim == 1: - X = np.atleast_2d(X) + X = np.atleast_2d(X) + y = np.atleast_2d(y) assert X.shape[-1] == len(self._problem_config.feat_list), ( "Argument 'X' has wrong dimensions. " f"Expected '(num_samples, {len(self._problem_config.feat_list)})', got '{X.shape}'." ) - if y.ndim == 1: - y = np.atleast_2d(y) - - assert (y.shape[-1] == 2 and len(self._problem_config.obj_list) == 1) or ( - y.shape[-1] == len(self._problem_config.obj_list) + assert y.shape[-2] == X.shape[-2] and y.shape[-1] == len( + self._problem_config.obj_list ), ( "Argument 'y' has wrong dimensions. " f"Expected '(num_samples, {len(self._problem_config.obj_list)})', got '{y.shape}'." diff --git a/entmoot/optimizers/pyomo_opt.py b/entmoot/optimizers/pyomo_opt.py index a878e03..252427d 100644 --- a/entmoot/optimizers/pyomo_opt.py +++ b/entmoot/optimizers/pyomo_opt.py @@ -1,3 +1,4 @@ +import warnings from typing import Optional import numpy as np @@ -97,6 +98,7 @@ def solve( # choose solver opt = pyo.SolverFactory( self._params["solver_name"], + solver_io=self._params.get("solver_io", "python"), manage_env="solver_factory_options" in self._params, options=self._params.get("solver_factory_options", {}), ) @@ -111,7 +113,10 @@ def solve( # Solve optimization model verbose = self._params.get("verbose", True) - opt.solve(opt_model, tee=verbose) + with warnings.catch_warnings(): + # pyomo<6.7.2 raises a warning when creating a Gurobi model + warnings.simplefilter("ignore", category=DeprecationWarning) + opt.solve(opt_model, tee=verbose) # update current solution self._curr_sol, self._active_leaves = self._get_sol(opt_model) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7d02f20..7db32d6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,6 @@ numpy<2.0.0 lightgbm>=4.0.0 -pyomo==6.7.0 +pyomo==6.7.1 gurobipy pytest-cov IPython diff --git a/requirements.txt b/requirements.txt index 3d8733d..d8a5762 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ numpy<2.0.0 lightgbm>=4.0.0 gurobipy -pyomo==6.7.0 \ No newline at end of file +pyomo==6.7.1 \ No newline at end of file diff --git a/setup.py b/setup.py index ebe0ffb..c3c3945 100644 --- a/setup.py +++ b/setup.py @@ -17,16 +17,6 @@ version=about["__version__"], url="https://github.com/cog-imperial/entmoot", packages=find_packages(exclude=["tests", "docs"]), - install_requires=[ - "numpy<=2.0.0", - "lightgbm>=4.0.0", - "gurobipy", - "pyomo==6.7.0" - ], - setup_requires=[ - "numpy<=2.0.0", - "lightgbm>=4.0.0", - "gurobipy", - "pyomo==6.7.0" - ], + install_requires=["numpy<=2.0.0", "lightgbm>=4.0.0", "gurobipy", "pyomo==6.7.1"], + setup_requires=["numpy<=2.0.0", "lightgbm>=4.0.0", "gurobipy", "pyomo==6.7.1"], ) diff --git a/tests/test_compare_problems.py b/tests/test_compare_problems.py new file mode 100644 index 0000000..a7d412d --- /dev/null +++ b/tests/test_compare_problems.py @@ -0,0 +1,158 @@ +"""Integration tests on benchmark problems, comparing Pyomo and Gurobi""" + +import random + +import numpy as np +import pytest + +from entmoot import Enting, GurobiOptimizer, ProblemConfig, PyomoOptimizer +from entmoot.benchmarks import ( + build_multi_obj_categorical_problem, + eval_multi_obj_cat_testfunc, +) +from entmoot.models.model_params import EntingParams, UncParams + + +@pytest.mark.pipeline_test +def test_multiobj_constrained_problem(): + # define problem + problem_config = ProblemConfig(rnd_seed=73) + # number of objectives + number_objectives = 2 + build_multi_obj_categorical_problem(problem_config, n_obj=number_objectives) + + # sample data + rnd_sample = problem_config.get_rnd_sample_list(num_samples=20) + testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=number_objectives) + + params = EntingParams( + unc_params=UncParams(dist_metric="l1", acq_sense="exploration") + ) + enting = Enting(problem_config, params=params) + # fit tree ensemble + enting.fit(rnd_sample, testfunc_evals) + + # Add constraints + + # Gurobi version + # get optimization model + model_gur = problem_config.get_gurobi_model_core() + # extract decision variables + x = model_gur._all_feat[3] + y = model_gur._all_feat[4] + z = model_gur._all_feat[5] + # add constraint that all variables should coincide + model_gur.addConstr(x == y) + model_gur.addConstr(y == z) + # Update model + model_gur.update() + + # Build GurobiOptimizer object and solve optimization problem + params_gurobi = {"MIPGap": 1e-3} + opt_gur = GurobiOptimizer(problem_config, params=params_gurobi) + + res_gur = opt_gur.solve(enting, model_core=model_gur) + x_opt = res_gur.opt_point[3] + + assert np.isclose(res_gur.opt_point[3:], x_opt, rtol=1.001).all() + + # Pyomo version + import pyomo.environ as pyo + + model_pyo = problem_config.get_pyomo_model_core() + # extract decision variables + x = model_pyo._all_feat[3] + y = model_pyo._all_feat[4] + z = model_pyo._all_feat[5] + # add constraint that all variables should coincide + model_pyo.xy_equal_constr = pyo.Constraint(expr=x == y) + model_pyo.yz_equal_constr = pyo.Constraint(expr=y == z) + + # Build GurobiOptimizer object and solve optimization problem + params_pyomo = {"solver_name": "gurobi"} + opt_pyo = PyomoOptimizer(problem_config, params=params_pyomo) + + res_pyo = opt_pyo.solve(enting, model_core=model_pyo) + x_opt = res_pyo.opt_point[3] + + assert np.isclose(res_gur.opt_point[3:], x_opt, rtol=1.001).all() + + +@pytest.mark.pipeline_test +def test_simple_one_dimensional_problem(): + random.seed(42) + + def my_func(x: float) -> float: + return x**2 + 1 + random.uniform(-0.2, 0.2) + + # Define a one-dimensional minimization problem with one real variable bounded by -2 and 3 + problem_config = ProblemConfig() + problem_config.add_feature("real", (-2, 3)) + problem_config.add_min_objective() + + # Create training data using the randomly disturbed function f(x) = x^2 + 1 + eps + X_train = np.reshape(np.linspace(-2, 3, 10), (-1, 1)) + y_train = np.reshape([my_func(x) for x in X_train], (-1, 1)) + + # Define enting object and corresponding parameters + params = EntingParams( + unc_params=UncParams(dist_metric="l1", acq_sense="exploration") + ) + enting = Enting(problem_config, params=params) + # Fit tree model + enting.fit(X_train, y_train) + + # Build PyomoOptimizer object with Gurobi as solver and solve optimization problem + params_pyo = {"solver_name": "gurobi"} + opt_pyo = PyomoOptimizer(problem_config, params=params_pyo) + res = opt_pyo.solve(enting) + + # very little guarantee about position of next proposal + # likely to be near center + assert -1.5 < res.opt_point[0] < 1.5 + + +@pytest.mark.pipeline_test +@pytest.mark.parametrize("dist_metric", ("l1", "l2", "euclidean_squared")) +@pytest.mark.parametrize("acq_sense", ("exploration", "penalty")) +@pytest.mark.parametrize("num_objectives", (1, 2)) +def test_compare_pyomo_gurobipy_singleobj(dist_metric, acq_sense, num_objectives): + """ + Ensures for a single objective example with l1 and l2 uncertainty metric and mixed feature types that optimization + results for Gurobipy model and Pyomo model with Gurobi as optimizer coincide. + """ + + # define problem + problem_config = ProblemConfig(rnd_seed=73) + # number of objectives + w = (0.4, 0.6) if num_objectives == 2 else None + build_multi_obj_categorical_problem(problem_config, n_obj=num_objectives) + + # sample data + rnd_sample = problem_config.get_rnd_sample_list(num_samples=20) + testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=num_objectives) + + params = EntingParams( + unc_params=UncParams(dist_metric=dist_metric, acq_sense=acq_sense) + ) + enting = Enting(problem_config, params=params) + # fit tree ensemble + enting.fit(rnd_sample, testfunc_evals) + + # Build GurobiOptimizer object and solve optimization problem + params_gurobi = {"NonConvex": 2, "MIPGap": 1e-3} + opt_gur = GurobiOptimizer(problem_config, params=params_gurobi) + res_gur = opt_gur.solve(enting, weights=w) + + # Build PyomoOptimizer object with Gurobi as solver and solve optimization problem + params_pyo = { + "solver_name": "gurobi", + "solver_options": {"NonConvex": 2, "MIPGap": 1e-3}, + } + opt_pyo = PyomoOptimizer(problem_config, params=params_pyo) + res_pyo = opt_pyo.solve(enting, weights=w) + + # Compare optimal values (e.g. objective values) ... + assert np.allclose(res_gur.opt_val, res_pyo.opt_val, rtol=1.001) + # ... and optimal points (e.g. feature variables) + assert np.allclose(res_gur.opt_point[2:], res_pyo.opt_point[2:], rtol=1.01) diff --git a/tests/test_curr.py b/tests/test_curr.py deleted file mode 100644 index d1afcfd..0000000 --- a/tests/test_curr.py +++ /dev/null @@ -1,218 +0,0 @@ -import math -import random - -import numpy as np -import pyomo.environ # noqa: F401 -import pytest - -from entmoot import Enting, GurobiOptimizer, ProblemConfig, PyomoOptimizer -from entmoot.benchmarks import ( - build_multi_obj_categorical_problem, - eval_multi_obj_cat_testfunc, -) -from entmoot.models.model_params import EntingParams, UncParams - - -@pytest.mark.pipeline_test -def test_core_model_copy(): - # define problem - problem_config = ProblemConfig(rnd_seed=73) - # number of objectives - number_objectives = 2 - build_multi_obj_categorical_problem(problem_config, n_obj=number_objectives) - - core_model_gurobi = problem_config.get_gurobi_model_core() - core_model_gurobi_copy = problem_config.copy_gurobi_model_core(core_model_gurobi) - - assert len(core_model_gurobi.getVars()) == len(core_model_gurobi_copy.getVars()) - assert len(core_model_gurobi._all_feat) == len(core_model_gurobi_copy._all_feat) - - core_model_pyomo = problem_config.get_pyomo_model_core() - core_model_pyomo_copy = problem_config.copy_pyomo_model_core(core_model_pyomo) - - assert len(core_model_pyomo.x) == len(core_model_pyomo_copy.x) - assert len(core_model_pyomo._all_feat) == len(core_model_pyomo_copy._all_feat) - - -@pytest.mark.pipeline_test -def test_multiobj_constraints(): - # define problem - problem_config = ProblemConfig(rnd_seed=73) - # number of objectives - number_objectives = 2 - build_multi_obj_categorical_problem(problem_config, n_obj=number_objectives) - - # sample data - rnd_sample = problem_config.get_rnd_sample_list(num_samples=20) - testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=number_objectives) - - params = EntingParams( - unc_params=UncParams(dist_metric="l1", acq_sense="exploration") - ) - enting = Enting(problem_config, params=params) - # fit tree ensemble - enting.fit(rnd_sample, testfunc_evals) - - # Add constraints - - # Gurobi version - # get optimization model - model_gur = problem_config.get_gurobi_model_core() - # extract decision variables - x = model_gur._all_feat[3] - y = model_gur._all_feat[4] - z = model_gur._all_feat[5] - # add constraint that all variables should coincide - model_gur.addConstr(x == y) - model_gur.addConstr(y == z) - # Update model - model_gur.update() - - # Build GurobiOptimizer object and solve optimization problem - params_gurobi = {"MIPGap": 1e-3} - opt_gur = GurobiOptimizer(problem_config, params=params_gurobi) - - res_gur = opt_gur.solve(enting, model_core=model_gur) - x_opt, y_opt, z_opt = res_gur.opt_point[3:] - - assert round(x_opt, 5) == round(y_opt, 5) and round(y_opt, 5) == round(z_opt, 5) - - # Pyomo version - import pyomo.environ as pyo - - model_pyo = problem_config.get_pyomo_model_core() - # extract decision variables - x = model_pyo._all_feat[3] - y = model_pyo._all_feat[4] - z = model_pyo._all_feat[5] - # add constraint that all variables should coincide - model_pyo.xy_equal_constr = pyo.Constraint(expr=x == y) - model_pyo.yz_equal_constr = pyo.Constraint(expr=y == z) - - # Build GurobiOptimizer object and solve optimization problem - params_pyomo = {"solver_name": "gurobi"} - opt_pyo = PyomoOptimizer(problem_config, params=params_pyomo) - - res_pyo = opt_pyo.solve(enting, model_core=model_pyo) - x_opt, y_opt, z_opt = res_pyo.opt_point[3:] - - assert round(x_opt, 5) == round(y_opt, 5) and round(y_opt, 5) == round(z_opt, 5) - - -@pytest.mark.pipeline_test -def test_simple_test(): - def my_func(x: float) -> float: - return x**2 + 1 + random.uniform(-0.2, 0.2) - - # Define a one-dimensional minimization problem with one real variable bounded by -2 and 3 - problem_config = ProblemConfig() - problem_config.add_feature("real", (-2, 3)) - problem_config.add_min_objective() - - # Create training data using the randomly disturbed function f(x) = x^2 + 1 + eps - X_train = np.reshape(np.linspace(-2, 3, 10), (-1, 1)) - y_train = np.reshape([my_func(x) for x in X_train], (-1, 1)) - - # Define enting object and corresponding parameters - params = EntingParams( - unc_params=UncParams(dist_metric="l1", acq_sense="exploration") - ) - enting = Enting(problem_config, params=params) - # Fit tree model - enting.fit(X_train, y_train) - - # Build PyomoOptimizer object with Gurobi as solver and solve optimization problem - params_pyo = {"solver_name": "gurobi"} - opt_pyo = PyomoOptimizer(problem_config, params=params_pyo) - res = opt_pyo.solve(enting) - - assert round(res.opt_point[0]) == 0 - - -def test_compare_pyomo_gurobipy_multiobj(): - """ - Ensures for a multi objective example with l1 and l2 uncertainty metric and mixed feature types that optimization - results for Gurobipy model and Pyomo model with Gurobi as optimizer coincide. - """ - - # define problem - problem_config = ProblemConfig(rnd_seed=73) - # number of objectives - number_objectives = 2 - build_multi_obj_categorical_problem(problem_config, n_obj=number_objectives) - - # sample data - rnd_sample = problem_config.get_rnd_sample_list(num_samples=20) - testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=number_objectives) - - for metric in ["l1", "l2", "euclidean_squared"]: - for acq_sense in ["exploration", "penalty"]: - params = EntingParams( - unc_params=UncParams(dist_metric=metric, acq_sense=acq_sense) - ) - enting = Enting(problem_config, params=params) - # fit tree ensemble - enting.fit(rnd_sample, testfunc_evals) - - # Build GurobiOptimizer object and solve optimization problem - params_gurobi = {"NonConvex": 2, "MIPGap": 0} - opt_gur = GurobiOptimizer(problem_config, params=params_gurobi) - res_gur = opt_gur.solve(enting, weights=(0.4, 0.6)) - - # Build PyomoOptimizer object with Gurobi as solver and solve optimization problem - params_pyo = { - "solver_name": "gurobi", - "solver_options": {"NonConvex": 2, "MIPGap": 0}, - } - opt_pyo = PyomoOptimizer(problem_config, params=params_pyo) - res_pyo = opt_pyo.solve(enting, weights=(0.4, 0.6)) - - # Assert that active leaves coincide for both models - assert math.isclose(res_gur.opt_val, res_pyo.opt_val, abs_tol=0.01) - - -@pytest.mark.pipeline_test -def test_compare_pyomo_gurobipy_singleobj(): - """ - Ensures for a single objective example with l1 and l2 uncertainty metric and mixed feature types that optimization - results for Gurobipy model and Pyomo model with Gurobi as optimizer coincide. - """ - - # define problem - problem_config = ProblemConfig(rnd_seed=73) - # number of objectives - number_objectives = 1 - build_multi_obj_categorical_problem(problem_config, n_obj=number_objectives) - - # sample data - rnd_sample = problem_config.get_rnd_sample_list(num_samples=20) - testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=number_objectives) - - for metric in ["l1", "l2", "euclidean_squared"]: - for acq_sense in ["exploration", "penalty"]: - params = EntingParams( - unc_params=UncParams(dist_metric=metric, acq_sense=acq_sense) - ) - enting = Enting(problem_config, params=params) - # fit tree ensemble - enting.fit(rnd_sample, testfunc_evals) - - # Build GurobiOptimizer object and solve optimization problem - params_gurobi = {"NonConvex": 2, "MIPGap": 1e-3} - opt_gur = GurobiOptimizer(problem_config, params=params_gurobi) - res_gur = opt_gur.solve(enting) - - # Build PyomoOptimizer object with Gurobi as solver and solve optimization problem - params_pyo = { - "solver_name": "gurobi", - "solver_options": {"NonConvex": 2, "MIPGap": 1e-3}, - } - opt_pyo = PyomoOptimizer(problem_config, params=params_pyo) - res_pyo = opt_pyo.solve(enting) - - # Compare optimal values (e.g. objective values) ... - assert round(res_gur.opt_val / res_pyo.opt_val, 5) <= 1.001 - # ... and optimal points (e.g. feature variables) - assert [round(x) for x in res_gur.opt_point[2:]] == [ - round(x) for x in res_pyo.opt_point[2:] - ] diff --git a/tests/test_objectives_pyomo.py b/tests/test_objectives.py similarity index 63% rename from tests/test_objectives_pyomo.py rename to tests/test_objectives.py index d250a9a..d0ab720 100644 --- a/tests/test_objectives_pyomo.py +++ b/tests/test_objectives.py @@ -1,3 +1,7 @@ +"""Test handling of objectives, and min/max problems""" + +import numpy as np +import pytest from pytest import approx from entmoot import Enting, ProblemConfig, PyomoOptimizer @@ -7,6 +11,28 @@ ) +@pytest.mark.pipeline_test +def test_incorrect_shape_of_observations_raises_error(): + problem_config = ProblemConfig(rnd_seed=73) + build_multi_obj_categorical_problem(problem_config, n_obj=1) + + rnd_sample = problem_config.get_rnd_sample_list(num_samples=20) + + params = {"unc_params": {"dist_metric": "l1", "acq_sense": "penalty"}} + enting = Enting(problem_config, params=params) + + with pytest.raises(AssertionError): + # too many objectives + testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=2) + enting.fit(rnd_sample, testfunc_evals) + + with pytest.raises(AssertionError): + # too few observations + testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=1)[:-3] + enting.fit(rnd_sample, testfunc_evals) + + +@pytest.mark.pipeline_test def test_max_predictions_equal_min_predictions(): """The sign of the predicted objective is independent of max/min.""" problem_config = ProblemConfig(rnd_seed=73) @@ -32,24 +58,22 @@ def test_max_predictions_equal_min_predictions(): pred_max = enting_max.predict(sample) for (m1, u1), (m2, u2) in zip(pred, pred_max): - print(">", m1, m2) - assert m1 == approx(m2, rel=1e-5) - assert u1 == approx(u2, rel=1e-5) + assert np.allclose(m1, m2, rtol=1e-5) + assert np.allclose(u1, u2, rtol=1e-5) +@pytest.mark.pipeline_test def test_max_objective_equals_minus_min_objective(): """Assert that the solution found by the minimiser is the same as that of the maximiser for the negative objective function""" problem_config = ProblemConfig(rnd_seed=73) build_multi_obj_categorical_problem(problem_config, n_obj=1) - problem_config.add_min_objective() problem_config_max = ProblemConfig(rnd_seed=73) build_multi_obj_categorical_problem(problem_config_max, n_obj=0) problem_config_max.add_max_objective() - problem_config_max.add_max_objective() rnd_sample = problem_config.get_rnd_sample_list(num_samples=20) - testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=2) + testfunc_evals = eval_multi_obj_cat_testfunc(rnd_sample, n_obj=1) params = {"unc_params": {"dist_metric": "l1", "acq_sense": "penalty"}} enting = Enting(problem_config, params=params) @@ -63,3 +87,13 @@ def test_max_objective_equals_minus_min_objective(): res_max = PyomoOptimizer(problem_config_max, params=params_pyomo).solve(enting) assert res.opt_point == approx(res_max.opt_point, rel=1e-5) + assert res.opt_val == approx(res_max.opt_val, rel=1e-3) + + # Note that OptResult.opt_val is *not* negated for maximization problems + # but enting.predict(x) *is* negated. + + ((mu,), std) = enting.predict([res.opt_point])[0] + ((mu_max,), std_max) = enting_max.predict([res.opt_point])[0] + + assert mu == approx(-mu_max, rel=1e-5) + assert std == approx(std_max, rel=1e-5) diff --git a/tests/test_optimizer_models.py b/tests/test_optimizer_models.py new file mode 100644 index 0000000..199472d --- /dev/null +++ b/tests/test_optimizer_models.py @@ -0,0 +1,43 @@ +import pytest + +from entmoot import ProblemConfig +from entmoot.benchmarks import ( + build_multi_obj_categorical_problem, +) + + +@pytest.mark.pipeline_test +def test_core_model_copy(): + def features_equal(model, model_copy, predicate): + for f, f_copy in zip(model._all_feat, model_copy._all_feat): + if type(f) is not type(f_copy): + return False + elif isinstance(f, dict): + for v, v_copy in zip(f.values(), f_copy.values()): + if not predicate(v, v_copy): + return False + else: + if not predicate(f, f_copy): + return False + return True + + # define problem + problem_config = ProblemConfig(rnd_seed=73) + # number of objectives + build_multi_obj_categorical_problem(problem_config, n_obj=2) + + core_model_gurobi = problem_config.get_gurobi_model_core() + core_model_gurobi_copy = problem_config.copy_gurobi_model_core(core_model_gurobi) + + assert len(core_model_gurobi.getVars()) == len(core_model_gurobi_copy.getVars()) + assert len(core_model_gurobi._all_feat) == len(core_model_gurobi_copy._all_feat) + predicate = lambda u, v: all((u.lb == v.lb, u.ub == v.ub, u.v_type == v.v_type)) + assert features_equal(core_model_gurobi, core_model_gurobi_copy, predicate) + + core_model_pyomo = problem_config.get_pyomo_model_core() + core_model_pyomo_copy = problem_config.copy_pyomo_model_core(core_model_pyomo) + + assert len(core_model_pyomo.x) == len(core_model_pyomo_copy.x) + assert len(core_model_pyomo._all_feat) == len(core_model_pyomo_copy._all_feat) + predicate = lambda u, v: all((u.lb == v.lb, u.ub == v.ub, u.domain == v.domain)) + assert features_equal(core_model_pyomo, core_model_pyomo_copy, predicate)