diff --git a/docs/customization.md b/docs/customization.md index b0887c1..ef260f9 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -79,7 +79,7 @@ from miplearn import PrimalSolutionComponent, MinPrecisionThreshold PrimalSolutionComponent( mode="heuristic", - threshold=lambda: MinPrecisionThreshold([0.80, 0.95]), + threshold=MinPrecisionThreshold([0.80, 0.95]), ) ``` @@ -159,14 +159,14 @@ dtype: float64 By default, given a training set of instantes, MIPLearn trains a fixed set of ML classifiers and regressors, then selects the best one based on cross-validation performance. Alternatively, the user may specify which ML model a component should use through the `classifier` or `regressor` contructor parameters. Scikit-learn classifiers and regressors are currently supported. A future version of the package will add compatibility with Keras models. -The example below shows how to construct a `PrimalSolutionComponent` which internally uses scikit-learn's `KNeighborsClassifiers`. Any other scikit-learn classifier or pipeline can be used. The classifier needs to be provided as a lambda function because the component may need to create multiple copies of it. It needs to be wrapped in `ScikitLearnClassifier` to ensure that all the proper data transformations are applied. +The example below shows how to construct a `PrimalSolutionComponent` which internally uses scikit-learn's `KNeighborsClassifiers`. Any other scikit-learn classifier or pipeline can be used. It needs to be wrapped in `ScikitLearnClassifier` to ensure that all the proper data transformations are applied. ```python from miplearn import PrimalSolutionComponent, ScikitLearnClassifier from sklearn.neighbors import KNeighborsClassifier comp = PrimalSolutionComponent( - classifier=lambda: ScikitLearnClassifier( + classifier=ScikitLearnClassifier( KNeighborsClassifier(n_neighbors=5), ), ) diff --git a/miplearn/components/cuts.py b/miplearn/components/cuts.py index c44c3da..aac4a1f 100644 --- a/miplearn/components/cuts.py +++ b/miplearn/components/cuts.py @@ -4,7 +4,6 @@ import logging import sys -from copy import deepcopy from typing import Any, Dict import numpy as np @@ -29,6 +28,7 @@ class UserCutsComponent(Component): classifier: Classifier = CountingClassifier(), threshold: float = 0.05, ): + assert isinstance(classifier, Classifier) self.threshold: float = threshold self.classifier_prototype: Classifier = classifier self.classifiers: Dict[Any, Classifier] = {} @@ -63,7 +63,7 @@ class UserCutsComponent(Component): continue for v in instance.found_violated_user_cuts: if v not in self.classifiers: - self.classifiers[v] = deepcopy(self.classifier_prototype) + self.classifiers[v] = self.classifier_prototype.clone() violation_to_instance_idx[v] = [] violation_to_instance_idx[v] += [idx] diff --git a/miplearn/components/lazy_dynamic.py b/miplearn/components/lazy_dynamic.py index 4f95ca7..8ffd542 100644 --- a/miplearn/components/lazy_dynamic.py +++ b/miplearn/components/lazy_dynamic.py @@ -4,7 +4,6 @@ import logging import sys -from copy import deepcopy from typing import Any, Dict import numpy as np @@ -29,6 +28,7 @@ class DynamicLazyConstraintsComponent(Component): classifier: Classifier = CountingClassifier(), threshold: float = 0.05, ): + assert isinstance(classifier, Classifier) self.threshold: float = threshold self.classifier_prototype: Classifier = classifier self.classifiers: Dict[Any, Classifier] = {} @@ -75,7 +75,7 @@ class DynamicLazyConstraintsComponent(Component): if isinstance(v, list): v = tuple(v) if v not in self.classifiers: - self.classifiers[v] = deepcopy(self.classifier_prototype) + self.classifiers[v] = self.classifier_prototype.clone() violation_to_instance_idx[v] = [] violation_to_instance_idx[v] += [idx] diff --git a/miplearn/components/lazy_static.py b/miplearn/components/lazy_static.py index 9e28b98..29432dc 100644 --- a/miplearn/components/lazy_static.py +++ b/miplearn/components/lazy_static.py @@ -4,12 +4,12 @@ import logging import sys -from copy import deepcopy -from typing import Any, Dict, Tuple, Optional +from typing import Dict, Tuple, Optional import numpy as np from tqdm.auto import tqdm +from miplearn import Classifier from miplearn.classifiers.counting import CountingClassifier from miplearn.components.component import Component from miplearn.types import TrainingSample, Features @@ -32,6 +32,7 @@ class StaticLazyConstraintsComponent(Component): large_gap=1e-2, violation_tolerance=-0.5, ): + assert isinstance(classifier, Classifier) self.threshold = threshold self.classifier_prototype = classifier self.classifiers = {} @@ -120,7 +121,7 @@ class StaticLazyConstraintsComponent(Component): x.keys(), desc="Fit (lazy)", disable=not sys.stdout.isatty() ): if category not in self.classifiers: - self.classifiers[category] = deepcopy(self.classifier_prototype) + self.classifiers[category] = self.classifier_prototype.clone() self.classifiers[category].fit(x[category], y[category]) def predict(self, instance): diff --git a/miplearn/components/objective.py b/miplearn/components/objective.py index 6d7e799..270a8f3 100644 --- a/miplearn/components/objective.py +++ b/miplearn/components/objective.py @@ -3,7 +3,7 @@ # Released under the modified BSD license. See COPYING.md for more details. import logging -from typing import List, Dict, Union, Callable, Optional, Any, TYPE_CHECKING, Tuple +from typing import List, Dict, Union, Optional, Any, TYPE_CHECKING, Tuple import numpy as np from sklearn.linear_model import LinearRegression @@ -16,10 +16,11 @@ from sklearn.metrics import ( ) from miplearn.classifiers import Regressor +from miplearn.classifiers.sklearn import ScikitLearnRegressor from miplearn.components.component import Component from miplearn.extractors import InstanceIterator from miplearn.instance import Instance -from miplearn.types import MIPSolveStats, TrainingSample, LearningSolveStats, Features +from miplearn.types import TrainingSample, LearningSolveStats, Features if TYPE_CHECKING: from miplearn.solvers.learning import LearningSolver @@ -34,13 +35,15 @@ class ObjectiveValueComponent(Component): def __init__( self, - lb_regressor: Callable[[], Regressor] = LinearRegression, - ub_regressor: Callable[[], Regressor] = LinearRegression, + lb_regressor: Regressor = ScikitLearnRegressor(LinearRegression()), + ub_regressor: Regressor = ScikitLearnRegressor(LinearRegression()), ) -> None: + assert isinstance(lb_regressor, Regressor) + assert isinstance(ub_regressor, Regressor) self.ub_regressor: Optional[Regressor] = None self.lb_regressor: Optional[Regressor] = None - self.lb_regressor_factory = lb_regressor - self.ub_regressor_factory = ub_regressor + self.lb_regressor_prototype = lb_regressor + self.ub_regressor_prototype = ub_regressor self._predicted_ub: Optional[float] = None self._predicted_lb: Optional[float] = None @@ -77,8 +80,8 @@ class ObjectiveValueComponent(Component): stats["Objective: predicted LB"] = self._predicted_lb def fit(self, training_instances: Union[List[str], List[Instance]]) -> None: - self.lb_regressor = self.lb_regressor_factory() - self.ub_regressor = self.ub_regressor_factory() + self.lb_regressor = self.lb_regressor_prototype.clone() + self.ub_regressor = self.ub_regressor_prototype.clone() logger.debug("Extracting features...") x_train = self.x(training_instances) y_train = self.y(training_instances) diff --git a/miplearn/components/primal.py b/miplearn/components/primal.py index 8d15c63..797a082 100644 --- a/miplearn/components/primal.py +++ b/miplearn/components/primal.py @@ -50,18 +50,18 @@ class PrimalSolutionComponent(Component): def __init__( self, - classifier: Callable[[], Classifier] = lambda: AdaptiveClassifier(), + classifier: Classifier = AdaptiveClassifier(), mode: str = "exact", - threshold: Callable[[], Threshold] = lambda: MinPrecisionThreshold( - [0.98, 0.98] - ), + threshold: Threshold = MinPrecisionThreshold([0.98, 0.98]), ) -> None: + assert isinstance(classifier, Classifier) + assert isinstance(threshold, Threshold) assert mode in ["exact", "heuristic"] self.mode = mode self.classifiers: Dict[Hashable, Classifier] = {} self.thresholds: Dict[Hashable, Threshold] = {} - self.threshold_factory = threshold - self.classifier_factory = classifier + self.threshold_prototype = threshold + self.classifier_prototype = classifier self.stats: Dict[str, float] = {} self._n_free = 0 self._n_zero = 0 @@ -114,8 +114,8 @@ class PrimalSolutionComponent(Component): y: Dict[str, np.ndarray], ) -> None: for category in x.keys(): - clf = self.classifier_factory() - thr = self.threshold_factory() + clf = self.classifier_prototype.clone() + thr = self.threshold_prototype.clone() clf.fit(x[category], y[category]) thr.fit(clf, x[category], y[category]) self.classifiers[category] = clf diff --git a/tests/components/test_lazy_dynamic.py b/tests/components/test_lazy_dynamic.py index d07e2d2..e3dfba7 100644 --- a/tests/components/test_lazy_dynamic.py +++ b/tests/components/test_lazy_dynamic.py @@ -22,6 +22,7 @@ def test_lazy_fit(): instances[0].found_violated_lazy_constraints = ["a", "b"] instances[1].found_violated_lazy_constraints = ["b", "c"] classifier = Mock(spec=Classifier) + classifier.clone = lambda: Mock(spec=Classifier) component = DynamicLazyConstraintsComponent(classifier=classifier) component.fit(instances) diff --git a/tests/components/test_objective.py b/tests/components/test_objective.py index a0f9215..f9d7dcc 100644 --- a/tests/components/test_objective.py +++ b/tests/components/test_objective.py @@ -38,11 +38,13 @@ def test_x_y_predict() -> None: # Construct mock regressors lb_regressor = Mock(spec=Regressor) lb_regressor.predict = Mock(return_value=np.array([[5.0], [6.0]])) + lb_regressor.clone = lambda: lb_regressor ub_regressor = Mock(spec=Regressor) ub_regressor.predict = Mock(return_value=np.array([[3.0], [3.0]])) + ub_regressor.clone = lambda: ub_regressor comp = ObjectiveValueComponent( - lb_regressor=lambda: lb_regressor, - ub_regressor=lambda: ub_regressor, + lb_regressor=lb_regressor, + ub_regressor=ub_regressor, ) # Should build x correctly @@ -77,9 +79,10 @@ def test_obj_evaluate(): instances, models = get_test_pyomo_instances() reg = Mock(spec=Regressor) reg.predict = Mock(return_value=np.array([[1000.0], [1000.0]])) + reg.clone = lambda: reg comp = ObjectiveValueComponent( - lb_regressor=lambda: reg, - ub_regressor=lambda: reg, + lb_regressor=reg, + ub_regressor=reg, ) comp.fit(instances) ev = comp.evaluate(instances) diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py index 8ce1aa6..6785843 100644 --- a/tests/components/test_primal.py +++ b/tests/components/test_primal.py @@ -189,10 +189,11 @@ def test_predict() -> None: def test_fit_xy(): - comp = PrimalSolutionComponent( - classifier=lambda: Mock(spec=Classifier), - threshold=lambda: Mock(spec=Threshold), - ) + clf = Mock(spec=Classifier) + clf.clone = lambda: Mock(spec=Classifier) + thr = Mock(spec=Threshold) + thr.clone = lambda: Mock(spec=Threshold) + comp = PrimalSolutionComponent(classifier=clf, threshold=thr) x = { "type-a": np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), "type-b": np.array([[7.0, 8.0, 9.0]]),