Repository: jnothman/searchgrid

Issue:
make_grid_search(list)
One should not need to create a pipeline just to search over multiple alternative estimators.

Discussion:

Patch:
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 98c5a72..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,29 +0,0 @@
-BSD 3-Clause License
-
-Copyright (c) 2017, Joel Nothman
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-* Redistributions of source code must retain the above copyright notice, this
-  list of conditions and the following disclaimer.
-
-* Redistributions in binary form must reproduce the above copyright notice,
-  this list of conditions and the following disclaimer in the documentation
-  and/or other materials provided with the distribution.
-
-* Neither the name of the copyright holder nor the names of its
-  contributors may be used to endorse or promote products derived from
-  this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.rst b/README.rst
index dabcc64..e57dd0d 100644
--- a/README.rst
+++ b/README.rst
@@ -47,6 +47,11 @@ and use in Python::
     search = make_grid_search(estimator, cv=..., scoring=...)
     search.fit(X, y)
 
+Or search for the best among multiple distinct estimators/pipelines::
+
+    search = make_grid_search([estimator1, estimator2], cv=..., scoring=...)
+    search.fit(X, y)
+
 Motivating examples
 ...................
 
@@ -147,7 +152,6 @@ Searching over multiple grids.
         ...                 reduce=[kbest, pca])
         >>> gs = make_grid_search(pipe)
 
-
 .. |py-versions| image:: https://img.shields.io/pypi/pyversions/Django.svg
     :alt: Python versions supported
 
diff --git a/searchgrid.py b/searchgrid.py
index dcbff65..bbe4862 100644
--- a/searchgrid.py
+++ b/searchgrid.py
@@ -1,7 +1,8 @@
-from collections import Mapping
-import itertools
+from collections import Mapping as _Mapping
+import itertools as _itertools
 
 from sklearn.model_selection import GridSearchCV as _GridSearchCV
+from sklearn.pipeline import Pipeline as _Pipeline
 
 
 def set_grid(estimator, **grid):
@@ -32,7 +33,7 @@ def _update_grid(dest, src, prefix=None):
         src = [{prefix + k: v for k, v in d.items()}
                for d in src]
     out = []
-    for d1, d2 in itertools.product(dest, src):
+    for d1, d2 in _itertools.product(dest, src):
         out_d = d1.copy()
         out_d.update(d2)
         out.append(out_d)
@@ -41,7 +42,7 @@ def _update_grid(dest, src, prefix=None):
 
 def _build_param_grid(estimator):
     grid = getattr(estimator, '_param_grid', {})
-    if isinstance(grid, Mapping):
+    if isinstance(grid, _Mapping):
         grid = [grid]
 
     # handle estimator parameters having their own grids
@@ -100,14 +101,26 @@ def build_param_grid(estimator):
     return out
 
 
+def _check_estimator(estimator):
+    if isinstance(estimator, list):
+        estimator = set_grid(_Pipeline([('root', estimator[0])]),
+                             root=estimator)
+    elif not hasattr(estimator, 'fit'):
+        raise ValueError('Expected estimator, but %r does not have .fit'
+                         % estimator)
+    return estimator
+
+
 def make_grid_search(estimator, **kwargs):
     """Construct a GridSearchCV with the given estimator and its set grid
 
     Parameters
     ----------
-    estimator : scikit-learn compatible estimator
+    estimator : (list of) estimator
+        When a list, the estimators are searched over.
     kwargs
         Other parameters to the
         :class:`sklearn.model_selection.GridSearchCV` constructor.
     """
+    estimator = _check_estimator(estimator)
     return _GridSearchCV(estimator, build_param_grid(estimator), **kwargs)


Test patch:
diff --git a/test_searchgrid.py b/test_searchgrid.py
index fccfb1d..242b268 100644
--- a/test_searchgrid.py
+++ b/test_searchgrid.py
@@ -3,7 +3,8 @@ from sklearn.pipeline import Pipeline, make_pipeline
 from sklearn.svm import SVC
 from sklearn.linear_model import LogisticRegression, SGDClassifier
 from sklearn.feature_selection import SelectKBest
-from searchgrid import set_grid, build_param_grid
+from sklearn.datasets import load_iris
+from searchgrid import set_grid, build_param_grid, make_grid_search
 
 
 @pytest.mark.parametrize(('estimator', 'param_grid'), [
@@ -45,3 +46,21 @@ def test_build_param_grid_set_estimator():
                    'clf__degree': [2, 3], 'sel__k': [2, 3]},
                   {'clf': [clf2, clf4], 'sel__k': [2, 3]}]
     assert build_param_grid(estimator) == param_grid
+
+
+def test_make_grid_search():
+    X, y = load_iris(return_X_y=True)
+    lr = LogisticRegression()
+    svc = set_grid(SVC(), kernel=['poly'], degree=[2, 3])
+    gs1 = make_grid_search(lr, cv=5)  # empty grid
+    gs2 = make_grid_search(svc, cv=5)
+    gs3 = make_grid_search([lr, svc], cv=5)
+    for gs, n_results in [(gs1, 1), (gs2, 2), (gs3, 3)]:
+        gs.fit(X, y)
+        assert gs.cv == 5
+        assert len(gs.cv_results_['params']) == n_results
+
+    svc_mask = gs3.cv_results_['param_root'] == svc
+    assert svc_mask.sum() == 2
+    assert gs3.cv_results_['param_root__degree'][svc_mask].tolist() == [2, 3]
+    assert gs3.cv_results_['param_root'][~svc_mask].tolist() == [lr]