MIPLearn v0.3

This commit is contained in:
2023-06-08 11:25:39 -05:00
parent 6cc253a903
commit 1ea989d48a
172 changed files with 10495 additions and 24812 deletions

View File

@@ -1,3 +1,3 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.

View File

View File

@@ -0,0 +1,26 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from typing import List, Dict, Any
from unittest.mock import Mock
from miplearn.components.primal.actions import SetWarmStart, FixVariables
from miplearn.components.primal.expert import ExpertPrimalComponent
def test_expert(multiknapsack_h5: List[str]) -> None:
model = Mock()
stats: Dict[str, Any] = {}
comp = ExpertPrimalComponent(action=SetWarmStart())
comp.before_mip(multiknapsack_h5[0], model, stats)
model.set_warm_starts.assert_called()
names, starts, _ = model.set_warm_starts.call_args.args
assert names.shape == (100,)
assert starts.shape == (1, 100)
comp = ExpertPrimalComponent(action=FixVariables())
comp.before_mip(multiknapsack_h5[0], model, stats)
model.fix_variables.assert_called()
names, v, _ = model.fix_variables.call_args.args
assert names.shape == (100,)
assert v.shape == (100,)

View File

@@ -0,0 +1,51 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from typing import List, Dict, Any
from unittest.mock import Mock, call
from sklearn.dummy import DummyClassifier
from miplearn.components.primal.actions import SetWarmStart
from miplearn.components.primal.indep import IndependentVarsPrimalComponent
from miplearn.extractors.fields import H5FieldsExtractor
def test_indep(multiknapsack_h5: List[str]) -> None:
# Create and fit component
clone_fn = Mock(return_value=Mock(wraps=DummyClassifier()))
comp = IndependentVarsPrimalComponent(
base_clf="dummy",
extractor=H5FieldsExtractor(var_fields=["lp_var_values"]),
clone_fn=clone_fn,
action=SetWarmStart(),
)
comp.fit(multiknapsack_h5)
# Should call clone 100 times and store the 100 classifiers
clone_fn.assert_has_calls([call("dummy") for _ in range(100)])
assert len(comp.clf_) == 100
for v in [b"x[0]", b"x[1]"]:
# Should pass correct data to fit
comp.clf_[v].fit.assert_called()
x, y = comp.clf_[v].fit.call_args.args
assert x.shape == (3, 1)
assert y.shape == (3,)
# Call before-mip
stats: Dict[str, Any] = {}
model = Mock()
comp.before_mip(multiknapsack_h5[0], model, stats)
# Should call predict with correct args
for v in [b"x[0]", b"x[1]"]:
comp.clf_[v].predict.assert_called()
(x_test,) = comp.clf_[v].predict.call_args.args
assert x_test.shape == (1, 1)
# Should set warm starts
model.set_warm_starts.assert_called()
names, starts, _ = model.set_warm_starts.call_args.args
assert len(names) == 100
assert starts.shape == (1, 100)

View File

@@ -0,0 +1,46 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from typing import List, Dict, Any
from unittest.mock import Mock
from sklearn.dummy import DummyClassifier
from miplearn.components.primal.actions import SetWarmStart
from miplearn.components.primal.joint import JointVarsPrimalComponent
from miplearn.extractors.fields import H5FieldsExtractor
def test_joint(multiknapsack_h5: List[str]) -> None:
# Create mock classifier
clf = Mock(wraps=DummyClassifier())
# Create and fit component
comp = JointVarsPrimalComponent(
clf=clf,
extractor=H5FieldsExtractor(instance_fields=["static_var_obj_coeffs"]),
action=SetWarmStart(),
)
comp.fit(multiknapsack_h5)
# Should call fit method with correct arguments
clf.fit.assert_called()
x, y = clf.fit.call_args.args
assert x.shape == (3, 100)
assert y.shape == (3, 100)
# Call before-mip
stats: Dict[str, Any] = {}
model = Mock()
comp.before_mip(multiknapsack_h5[0], model, stats)
# Should call predict with correct args
clf.predict.assert_called()
(x_test,) = clf.predict.call_args.args
assert x_test.shape == (1, 100)
# Should set warm starts
model.set_warm_starts.assert_called()
names, starts, _ = model.set_warm_starts.call_args.args
assert len(names) == 100
assert starts.shape == (1, 100)

View File

@@ -0,0 +1,86 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import List, Dict, Any
from unittest.mock import Mock
import numpy as np
from sklearn.dummy import DummyClassifier
from miplearn.components.primal.actions import SetWarmStart
from miplearn.components.primal.mem import (
MemorizingPrimalComponent,
SelectTopSolutions,
MergeTopSolutions,
)
from miplearn.extractors.abstract import FeaturesExtractor
logger = logging.getLogger(__name__)
def test_mem_component(
multiknapsack_h5: List[str], default_extractor: FeaturesExtractor
) -> None:
# Create mock classifier
clf = Mock(wraps=DummyClassifier())
# Create and fit component
comp = MemorizingPrimalComponent(
clf,
extractor=default_extractor,
constructor=SelectTopSolutions(2),
action=SetWarmStart(),
)
comp.fit(multiknapsack_h5)
# Should call fit method with correct arguments
clf.fit.assert_called()
x, y = clf.fit.call_args.args
assert x.shape == (3, 100)
assert y.tolist() == [0, 1, 2]
# Should store solutions
assert comp.solutions_ is not None
assert comp.solutions_.shape == (3, 100)
assert comp.bin_var_names_ is not None
assert len(comp.bin_var_names_) == 100
# Call before-mip
stats: Dict[str, Any] = {}
model = Mock()
comp.before_mip(multiknapsack_h5[0], model, stats)
# Should call predict_proba with correct args
clf.predict_proba.assert_called()
(x_test,) = clf.predict_proba.call_args.args
assert x_test.shape == (1, 100)
# Should set warm starts
model.set_warm_starts.assert_called()
names, starts, _ = model.set_warm_starts.call_args.args
assert len(names) == 100
assert starts.shape == (2, 100)
assert np.all(starts[0, :] == comp.solutions_[0, :])
assert np.all(starts[1, :] == comp.solutions_[1, :])
def test_merge_top_solutions() -> None:
solutions = np.array(
[
[0, 1, 0, 0],
[0, 1, 0, 1],
[0, 1, 1, 1],
[0, 1, 1, 1],
[1, 1, 1, 1],
]
)
y_proba = np.array([0.25, 0.25, 0.25, 0.25, 0])
starts = MergeTopSolutions(k=4, thresholds=[0.25, 0.75]).construct(
y_proba, solutions
)
assert starts.shape == (1, 4)
assert starts[0, 0] == 0
assert starts[0, 1] == 1
assert np.isnan(starts[0, 2])
assert starts[0, 3] == 1

View File

@@ -1,147 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from typing import List, cast
from unittest.mock import Mock
import numpy as np
import pytest
from miplearn.classifiers import Classifier
from miplearn.classifiers.threshold import MinProbabilityThreshold
from miplearn.components import classifier_evaluation_dict
from miplearn.components.dynamic_common import DynamicConstraintsComponent
from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent
from miplearn.features.sample import MemorySample
from miplearn.instance.base import Instance
from miplearn.solvers.tests import assert_equals
E = 0.1
@pytest.fixture
def training_instances() -> List[Instance]:
instances = [cast(Instance, Mock(spec=Instance)) for _ in range(2)]
samples_0 = [
MemorySample(
{
"mip_constr_lazy": DynamicConstraintsComponent.encode(
{
b"c1": 0,
b"c2": 0,
}
),
"static_instance_features": np.array([5.0]),
},
),
MemorySample(
{
"mip_constr_lazy": DynamicConstraintsComponent.encode(
{
b"c2": 0,
b"c3": 0,
}
),
"static_instance_features": np.array([5.0]),
},
),
]
instances[0].get_samples = Mock(return_value=samples_0) # type: ignore
instances[0].get_constraint_categories = Mock( # type: ignore
return_value=np.array(["type-a", "type-a", "type-b", "type-b"], dtype="S")
)
instances[0].get_constraint_features = Mock( # type: ignore
return_value=np.array(
[
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[1.0, 2.0, 0.0],
[3.0, 4.0, 0.0],
]
)
)
instances[0].are_constraints_lazy = Mock( # type: ignore
return_value=np.zeros(4, dtype=bool)
)
samples_1 = [
MemorySample(
{
"mip_constr_lazy": DynamicConstraintsComponent.encode(
{
b"c3": 0,
b"c4": 0,
}
),
"static_instance_features": np.array([8.0]),
},
)
]
instances[1].get_samples = Mock(return_value=samples_1) # type: ignore
instances[1].get_constraint_categories = Mock( # type: ignore
return_value=np.array(["", "type-a", "type-b", "type-b"], dtype="S")
)
instances[1].get_constraint_features = Mock( # type: ignore
return_value=np.array(
[
[7.0, 8.0, 9.0],
[5.0, 6.0, 0.0],
[7.0, 8.0, 0.0],
]
)
)
instances[1].are_constraints_lazy = Mock( # type: ignore
return_value=np.zeros(4, dtype=bool)
)
return instances
def test_sample_xy(training_instances: List[Instance]) -> None:
comp = DynamicLazyConstraintsComponent()
comp.pre_fit(
[
{b"c1": 0, b"c3": 0, b"c4": 0},
{b"c1": 0, b"c2": 0, b"c4": 0},
]
)
x_expected = {
b"type-a": np.array([[5.0, 1.0, 2.0, 3.0], [5.0, 4.0, 5.0, 6.0]]),
b"type-b": np.array([[5.0, 1.0, 2.0, 0.0], [5.0, 3.0, 4.0, 0.0]]),
}
y_expected = {
b"type-a": np.array([[False, True], [False, True]]),
b"type-b": np.array([[True, False], [True, False]]),
}
x_actual, y_actual = comp.sample_xy(
training_instances[0],
training_instances[0].get_samples()[0],
)
assert_equals(x_actual, x_expected)
assert_equals(y_actual, y_expected)
def test_sample_predict_evaluate(training_instances: List[Instance]) -> None:
comp = DynamicLazyConstraintsComponent()
comp.known_violations[b"c1"] = 0
comp.known_violations[b"c2"] = 0
comp.known_violations[b"c3"] = 0
comp.known_violations[b"c4"] = 0
comp.thresholds[b"type-a"] = MinProbabilityThreshold([0.5, 0.5])
comp.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5])
comp.classifiers[b"type-a"] = Mock(spec=Classifier)
comp.classifiers[b"type-b"] = Mock(spec=Classifier)
comp.classifiers[b"type-a"].predict_proba = Mock( # type: ignore
side_effect=lambda _: np.array([[0.1, 0.9], [0.8, 0.2]])
)
comp.classifiers[b"type-b"].predict_proba = Mock( # type: ignore
side_effect=lambda _: np.array([[0.9, 0.1], [0.1, 0.9]])
)
pred = comp.sample_predict(
training_instances[0],
training_instances[0].get_samples()[0],
)
assert pred == [b"c1", b"c4"]
ev = comp.sample_evaluate(
training_instances[0],
training_instances[0].get_samples()[0],
)
assert ev == classifier_evaluation_dict(tp=1, fp=1, tn=1, fn=1)

View File

@@ -1,105 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import json
import logging
from typing import Any, List, Dict
import gurobipy
import gurobipy as gp
import networkx as nx
import pytest
from gurobipy import GRB
from networkx import Graph
from overrides import overrides
from miplearn.components.dynamic_user_cuts import UserCutsComponent
from miplearn.instance.base import Instance
from miplearn.solvers.gurobi import GurobiSolver
from miplearn.solvers.learning import LearningSolver
from miplearn.types import ConstraintName
logger = logging.getLogger(__name__)
class GurobiStableSetProblem(Instance):
def __init__(self, graph: Graph) -> None:
super().__init__()
self.graph: Graph = graph
@overrides
def to_model(self) -> Any:
model = gp.Model()
x = [model.addVar(vtype=GRB.BINARY) for _ in range(len(self.graph.nodes))]
model.setObjective(gp.quicksum(x), GRB.MAXIMIZE)
for e in list(self.graph.edges):
model.addConstr(x[e[0]] + x[e[1]] <= 1)
return model
@overrides
def has_user_cuts(self) -> bool:
return True
@overrides
def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]:
assert isinstance(model, gp.Model)
try:
vals = model.cbGetNodeRel(model.getVars())
except gurobipy.GurobiError:
return {}
violations = {}
for clique in nx.find_cliques(self.graph):
if sum(vals[i] for i in clique) > 1:
vname = (",".join([str(i) for i in clique])).encode()
violations[vname] = list(clique)
return violations
@overrides
def enforce_user_cut(
self,
solver: GurobiSolver,
model: Any,
clique: List[int],
) -> Any:
x = model.getVars()
constr = gp.quicksum([x[i] for i in clique]) <= 1
if solver.cb_where:
model.cbCut(constr)
else:
model.addConstr(constr)
@pytest.fixture
def stab_instance() -> Instance:
graph = nx.generators.random_graphs.binomial_graph(50, 0.50, seed=42)
return GurobiStableSetProblem(graph)
@pytest.fixture
def solver() -> LearningSolver:
return LearningSolver(
solver=GurobiSolver(params={"Threads": 1}),
components=[UserCutsComponent()],
)
def test_usage(
stab_instance: Instance,
solver: LearningSolver,
) -> None:
stats_before = solver._solve(stab_instance)
sample = stab_instance.get_samples()[0]
user_cuts_encoded = sample.get_scalar("mip_user_cuts")
assert user_cuts_encoded is not None
user_cuts = json.loads(user_cuts_encoded)
assert user_cuts is not None
assert len(user_cuts) > 0
assert stats_before["UserCuts: Added ahead-of-time"] == 0
assert stats_before["UserCuts: Added in callback"] > 0
solver._fit([stab_instance])
stats_after = solver._solve(stab_instance)
assert (
stats_after["UserCuts: Added ahead-of-time"]
== stats_before["UserCuts: Added in callback"]
)

View File

@@ -1,141 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from typing import Dict
from unittest.mock import Mock
import numpy as np
import pytest
from numpy.testing import assert_array_equal
from miplearn.classifiers import Regressor
from miplearn.components.objective import ObjectiveValueComponent
from miplearn.features.sample import Sample, MemorySample
from miplearn.solvers.learning import LearningSolver
from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver
from miplearn.solvers.tests import assert_equals
@pytest.fixture
def sample() -> Sample:
sample = MemorySample(
{
"mip_lower_bound": 1.0,
"mip_upper_bound": 2.0,
"lp_instance_features": np.array([1.0, 2.0, 3.0]),
},
)
return sample
def test_sample_xy(sample: Sample) -> None:
x_expected = {
"Lower bound": np.array([[1.0, 2.0, 3.0]]),
"Upper bound": np.array([[1.0, 2.0, 3.0]]),
}
y_expected = {
"Lower bound": np.array([[1.0]]),
"Upper bound": np.array([[2.0]]),
}
xy = ObjectiveValueComponent().sample_xy(None, sample)
assert xy is not None
x_actual, y_actual = xy
assert_equals(x_actual, x_expected)
assert_equals(y_actual, y_expected)
def test_fit_xy() -> None:
x: Dict[str, np.ndarray] = {
"Lower bound": np.array([[0.0, 0.0], [1.0, 2.0]]),
"Upper bound": np.array([[0.0, 0.0], [1.0, 2.0]]),
}
y: Dict[str, np.ndarray] = {
"Lower bound": np.array([[100.0]]),
"Upper bound": np.array([[200.0]]),
}
reg = Mock(spec=Regressor)
reg.clone = Mock(side_effect=lambda: Mock(spec=Regressor))
comp = ObjectiveValueComponent(regressor=reg)
assert "Upper bound" not in comp.regressors
assert "Lower bound" not in comp.regressors
comp.fit_xy(x, y)
assert reg.clone.call_count == 2
assert "Upper bound" in comp.regressors
assert "Lower bound" in comp.regressors
assert comp.regressors["Upper bound"].fit.call_count == 1 # type: ignore
assert comp.regressors["Lower bound"].fit.call_count == 1 # type: ignore
assert_array_equal(
comp.regressors["Upper bound"].fit.call_args[0][0], # type: ignore
x["Upper bound"],
)
assert_array_equal(
comp.regressors["Lower bound"].fit.call_args[0][0], # type: ignore
x["Lower bound"],
)
assert_array_equal(
comp.regressors["Upper bound"].fit.call_args[0][1], # type: ignore
y["Upper bound"],
)
assert_array_equal(
comp.regressors["Lower bound"].fit.call_args[0][1], # type: ignore
y["Lower bound"],
)
def test_sample_predict(sample: Sample) -> None:
x, y = ObjectiveValueComponent().sample_xy(None, sample)
comp = ObjectiveValueComponent()
comp.regressors["Lower bound"] = Mock(spec=Regressor)
comp.regressors["Upper bound"] = Mock(spec=Regressor)
comp.regressors["Lower bound"].predict = Mock( # type: ignore
side_effect=lambda _: np.array([[50.0]])
)
comp.regressors["Upper bound"].predict = Mock( # type: ignore
side_effect=lambda _: np.array([[60.0]])
)
pred = comp.sample_predict(sample)
assert pred == {
"Lower bound": 50.0,
"Upper bound": 60.0,
}
assert_array_equal(
comp.regressors["Upper bound"].predict.call_args[0][0], # type: ignore
x["Upper bound"],
)
assert_array_equal(
comp.regressors["Lower bound"].predict.call_args[0][0], # type: ignore
x["Lower bound"],
)
def test_sample_evaluate(sample: Sample) -> None:
comp = ObjectiveValueComponent()
comp.regressors["Lower bound"] = Mock(spec=Regressor)
comp.regressors["Lower bound"].predict = lambda _: np.array([[1.05]]) # type: ignore
comp.regressors["Upper bound"] = Mock(spec=Regressor)
comp.regressors["Upper bound"].predict = lambda _: np.array([[2.50]]) # type: ignore
ev = comp.sample_evaluate(None, sample)
assert ev == {
"Lower bound": {
"Actual value": 1.0,
"Predicted value": 1.05,
"Absolute error": 0.05,
"Relative error": 0.05,
},
"Upper bound": {
"Actual value": 2.0,
"Predicted value": 2.50,
"Absolute error": 0.5,
"Relative error": 0.25,
},
}
def test_usage() -> None:
solver = LearningSolver(components=[ObjectiveValueComponent()])
instance = GurobiPyomoSolver().build_test_instance_knapsack()
solver._solve(instance)
solver._fit([instance])
stats = solver._solve(instance)
assert stats["mip_lower_bound"] == stats["Objective: Predicted lower bound"]
assert stats["mip_upper_bound"] == stats["Objective: Predicted upper bound"]

View File

@@ -1,166 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from unittest.mock import Mock
import numpy as np
import pytest
from numpy.testing import assert_array_equal
from scipy.stats import randint
from miplearn.classifiers import Classifier
from miplearn.classifiers.threshold import Threshold
from miplearn.components import classifier_evaluation_dict
from miplearn.components.primal import PrimalSolutionComponent
from miplearn.features.sample import Sample, MemorySample
from miplearn.problems.tsp import TravelingSalesmanGenerator, TravelingSalesmanInstance
from miplearn.solvers.learning import LearningSolver
from miplearn.solvers.tests import assert_equals
@pytest.fixture
def sample() -> Sample:
sample = MemorySample(
{
"static_var_names": np.array(["x[0]", "x[1]", "x[2]", "x[3]"], dtype="S"),
"static_var_types": np.array(["B", "B", "B", "B"], dtype="S"),
"static_var_categories": np.array(
["default", "", "default", "default"],
dtype="S",
),
"mip_var_values": np.array([0.0, 1.0, 1.0, 0.0]),
"static_instance_features": np.array([5.0]),
"static_var_features": np.array(
[
[0.0, 0.0],
[0.0, 0.0],
[1.0, 0.0],
[1.0, 1.0],
]
),
"lp_var_features": np.array(
[
[0.0, 0.0, 2.0, 2.0],
[0.0, 0.0, 0.0, 0.0],
[1.0, 0.0, 3.0, 2.0],
[1.0, 1.0, 3.0, 3.0],
]
),
},
)
return sample
def test_xy(sample: Sample) -> None:
x_expected = {
b"default": [
[5.0, 0.0, 0.0, 2.0, 2.0],
[5.0, 1.0, 0.0, 3.0, 2.0],
[5.0, 1.0, 1.0, 3.0, 3.0],
]
}
y_expected = {
b"default": [
[True, False],
[False, True],
[True, False],
]
}
xy = PrimalSolutionComponent().sample_xy(None, sample)
assert xy is not None
x_actual, y_actual = xy
assert x_actual == x_expected
assert y_actual == y_expected
def test_fit_xy() -> None:
clf = Mock(spec=Classifier)
clf.clone = lambda: Mock(spec=Classifier) # type: ignore
thr = Mock(spec=Threshold)
thr.clone = lambda: Mock(spec=Threshold)
comp = PrimalSolutionComponent(classifier=clf, threshold=thr)
x = {
b"type-a": np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]),
b"type-b": np.array([[7.0, 8.0, 9.0]]),
}
y = {
b"type-a": np.array([[True, False], [False, True]]),
b"type-b": np.array([[True, False]]),
}
comp.fit_xy(x, y)
for category in [b"type-a", b"type-b"]:
assert category in comp.classifiers
assert category in comp.thresholds
clf = comp.classifiers[category] # type: ignore
clf.fit.assert_called_once()
assert_array_equal(x[category], clf.fit.call_args[0][0])
assert_array_equal(y[category], clf.fit.call_args[0][1])
thr = comp.thresholds[category] # type: ignore
thr.fit.assert_called_once()
assert_array_equal(x[category], thr.fit.call_args[0][1])
assert_array_equal(y[category], thr.fit.call_args[0][2])
def test_usage() -> None:
solver = LearningSolver(
components=[
PrimalSolutionComponent(),
]
)
gen = TravelingSalesmanGenerator(n=randint(low=5, high=6))
data = gen.generate(1)
instance = TravelingSalesmanInstance(data[0].n_cities, data[0].distances)
solver._solve(instance)
solver._fit([instance])
stats = solver._solve(instance)
assert stats["Primal: Free"] == 0
assert stats["Primal: One"] + stats["Primal: Zero"] == 10
assert stats["mip_lower_bound"] == stats["mip_warm_start_value"]
def test_evaluate(sample: Sample) -> None:
comp = PrimalSolutionComponent()
comp.sample_predict = lambda _: { # type: ignore
b"x[0]": 1.0,
b"x[1]": 1.0,
b"x[2]": 0.0,
b"x[3]": None,
}
ev = comp.sample_evaluate(None, sample)
assert_equals(
ev,
{
"0": classifier_evaluation_dict(tp=0, fp=1, tn=1, fn=2),
"1": classifier_evaluation_dict(tp=1, fp=1, tn=1, fn=1),
},
)
def test_predict(sample: Sample) -> None:
clf = Mock(spec=Classifier)
clf.predict_proba = Mock(
return_value=np.array(
[
[0.9, 0.1],
[0.5, 0.5],
[0.1, 0.9],
]
)
)
thr = Mock(spec=Threshold)
thr.predict = Mock(return_value=[0.75, 0.75])
comp = PrimalSolutionComponent()
x, _ = comp.sample_xy(None, sample)
comp.classifiers = {b"default": clf}
comp.thresholds = {b"default": thr}
pred = comp.sample_predict(sample)
clf.predict_proba.assert_called_once()
thr.predict.assert_called_once()
assert_array_equal(x[b"default"], clf.predict_proba.call_args[0][0])
assert_array_equal(x[b"default"], thr.predict.call_args[0][0])
assert pred == {
b"x[0]": 0.0,
b"x[1]": None,
b"x[2]": None,
b"x[3]": 1.0,
}

View File

@@ -1,238 +0,0 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from typing import Dict, cast
from unittest.mock import Mock, call
import numpy as np
import pytest
from numpy.testing import assert_array_equal
from miplearn.classifiers import Classifier
from miplearn.classifiers.threshold import Threshold, MinProbabilityThreshold
from miplearn.components.static_lazy import StaticLazyConstraintsComponent
from miplearn.features.sample import Sample, MemorySample
from miplearn.instance.base import Instance
from miplearn.solvers.internal import InternalSolver, Constraints
from miplearn.solvers.learning import LearningSolver
from miplearn.types import (
LearningSolveStats,
ConstraintCategory,
)
from miplearn.solvers.tests import assert_equals
@pytest.fixture
def sample() -> Sample:
sample = MemorySample(
{
"static_constr_categories": [
b"type-a",
b"type-a",
b"type-a",
b"type-b",
b"type-b",
],
"static_constr_lazy": np.array([True, True, True, True, False]),
"static_constr_names": np.array(["c1", "c2", "c3", "c4", "c5"], dtype="S"),
"static_instance_features": [5.0],
"mip_constr_lazy_enforced": np.array(["c1", "c2", "c4"], dtype="S"),
"lp_constr_features": np.array(
[
[1.0, 1.0, 0.0],
[1.0, 2.0, 0.0],
[1.0, 3.0, 0.0],
[1.0, 4.0, 0.0],
[0.0, 0.0, 0.0],
]
),
"static_constr_lazy_count": 4,
},
)
return sample
@pytest.fixture
def instance(sample: Sample) -> Instance:
instance = Mock(spec=Instance)
instance.get_samples = Mock(return_value=[sample]) # type: ignore
instance.has_static_lazy_constraints = Mock(return_value=True)
return instance
def test_usage_with_solver(instance: Instance) -> None:
solver = Mock(spec=LearningSolver)
solver.use_lazy_cb = False
solver.gap_tolerance = 1e-4
internal = solver.internal_solver = Mock(spec=InternalSolver)
internal.is_constraint_satisfied_old = Mock(return_value=False)
internal.are_constraints_satisfied = Mock(
side_effect=lambda cf, tol=1.0: [False for i in range(len(cf.names))]
)
component = StaticLazyConstraintsComponent(violation_tolerance=1.0)
component.thresholds[b"type-a"] = MinProbabilityThreshold([0.5, 0.5])
component.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5])
component.classifiers = {
b"type-a": Mock(spec=Classifier),
b"type-b": Mock(spec=Classifier),
}
component.classifiers[b"type-a"].predict_proba = Mock( # type: ignore
return_value=np.array(
[
[0.00, 1.00], # c1
[0.20, 0.80], # c2
[0.99, 0.01], # c3
]
)
)
component.classifiers[b"type-b"].predict_proba = Mock( # type: ignore
return_value=np.array(
[
[0.02, 0.98], # c4
]
)
)
stats: LearningSolveStats = {}
sample = instance.get_samples()[0]
assert sample.get_array("mip_constr_lazy_enforced") is not None
# LearningSolver calls before_solve_mip
component.before_solve_mip(
solver=solver,
instance=instance,
model=None,
stats=stats,
sample=sample,
)
# Should ask ML to predict whether each lazy constraint should be enforced
component.classifiers[b"type-a"].predict_proba.assert_called_once()
component.classifiers[b"type-b"].predict_proba.assert_called_once()
# Should ask internal solver to remove some constraints
assert internal.remove_constraints.call_count == 1
internal.remove_constraints.assert_has_calls([call([b"c3"])])
# LearningSolver calls after_iteration (first time)
should_repeat = component.iteration_cb(solver, instance, None)
assert should_repeat
# Should ask internal solver to verify if constraints in the pool are
# satisfied and add the ones that are not
c = Constraints.from_sample(sample)[[False, False, True, False, False]]
internal.are_constraints_satisfied.assert_called_once_with(c, tol=1.0)
internal.are_constraints_satisfied.reset_mock()
internal.add_constraints.assert_called_once_with(c)
internal.add_constraints.reset_mock()
# LearningSolver calls after_iteration (second time)
should_repeat = component.iteration_cb(solver, instance, None)
assert not should_repeat
# The lazy constraint pool should be empty by now, so no calls should be made
internal.are_constraints_satisfied.assert_not_called()
internal.add_constraints.assert_not_called()
# LearningSolver calls after_solve_mip
component.after_solve_mip(
solver=solver,
instance=instance,
model=None,
stats=stats,
sample=sample,
)
# Should update training sample
mip_constr_lazy_enforced = sample.get_array("mip_constr_lazy_enforced")
assert mip_constr_lazy_enforced is not None
assert_equals(
sorted(mip_constr_lazy_enforced),
np.array(["c1", "c2", "c3", "c4"], dtype="S"),
)
# Should update stats
assert stats["LazyStatic: Removed"] == 1
assert stats["LazyStatic: Kept"] == 3
assert stats["LazyStatic: Restored"] == 1
assert stats["LazyStatic: Iterations"] == 1
def test_sample_predict(sample: Sample) -> None:
comp = StaticLazyConstraintsComponent()
comp.thresholds[b"type-a"] = MinProbabilityThreshold([0.5, 0.5])
comp.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5])
comp.classifiers[b"type-a"] = Mock(spec=Classifier)
comp.classifiers[b"type-a"].predict_proba = lambda _: np.array( # type:ignore
[
[0.0, 1.0], # c1
[0.0, 0.9], # c2
[0.9, 0.1], # c3
]
)
comp.classifiers[b"type-b"] = Mock(spec=Classifier)
comp.classifiers[b"type-b"].predict_proba = lambda _: np.array( # type:ignore
[
[0.0, 1.0], # c4
]
)
pred = comp.sample_predict(sample)
assert pred == [b"c1", b"c2", b"c4"]
def test_fit_xy() -> None:
x = cast(
Dict[ConstraintCategory, np.ndarray],
{
b"type-a": np.array([[1.0, 1.0], [1.0, 2.0], [1.0, 3.0]]),
b"type-b": np.array([[1.0, 4.0, 0.0]]),
},
)
y = cast(
Dict[ConstraintCategory, np.ndarray],
{
b"type-a": np.array([[False, True], [False, True], [True, False]]),
b"type-b": np.array([[False, True]]),
},
)
clf: Classifier = Mock(spec=Classifier)
thr: Threshold = Mock(spec=Threshold)
clf.clone = Mock(side_effect=lambda: Mock(spec=Classifier)) # type: ignore
thr.clone = Mock(side_effect=lambda: Mock(spec=Threshold)) # type: ignore
comp = StaticLazyConstraintsComponent(
classifier=clf,
threshold=thr,
)
comp.fit_xy(x, y)
assert clf.clone.call_count == 2
clf_a = comp.classifiers[b"type-a"]
clf_b = comp.classifiers[b"type-b"]
assert clf_a.fit.call_count == 1 # type: ignore
assert clf_b.fit.call_count == 1 # type: ignore
assert_array_equal(clf_a.fit.call_args[0][0], x[b"type-a"]) # type: ignore
assert_array_equal(clf_b.fit.call_args[0][0], x[b"type-b"]) # type: ignore
assert thr.clone.call_count == 2
thr_a = comp.thresholds[b"type-a"]
thr_b = comp.thresholds[b"type-b"]
assert thr_a.fit.call_count == 1 # type: ignore
assert thr_b.fit.call_count == 1 # type: ignore
assert thr_a.fit.call_args[0][0] == clf_a # type: ignore
assert thr_b.fit.call_args[0][0] == clf_b # type: ignore
def test_sample_xy(sample: Sample) -> None:
x_expected = {
b"type-a": [[5.0, 1.0, 1.0, 0.0], [5.0, 1.0, 2.0, 0.0], [5.0, 1.0, 3.0, 0.0]],
b"type-b": [[5.0, 1.0, 4.0, 0.0]],
}
y_expected = {
b"type-a": [[False, True], [False, True], [True, False]],
b"type-b": [[False, True]],
}
xy = StaticLazyConstraintsComponent().sample_xy(None, sample)
assert xy is not None
x_actual, y_actual = xy
assert x_actual == x_expected
assert y_actual == y_expected