Repository: jnothman/searchgrid

Issue:
Shorthands for gridded pipelines and feature_unions
Could have:

```python
searchgrid.make_union([OptionalTransformer1(), None],
                      [OptionalTransformer2(), OptionalTransformer3()],
                      Transformer4())
```

translate to

```python
set_grid(FeatureUnion([('optionaltransformer1', None), ('auto1', None),
                       ('transformer4', Transformer4())]),
         optionaltransformer1=[OptionalTransformer1(), None],
         auto1=[OptionalTransformer2(), OptionalTransformer3()])
```

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 980eaa0..c917f94 100644
--- a/README.rst
+++ b/README.rst
@@ -32,6 +32,12 @@ It provides two main functions:
    ``GridSearchCV`` object using the parameter space the estimator is annotated
    with.
 
+Other utilities for constructing search spaces include:
+
+- `searchgrid.build_param_grid`
+- `searchgrid.make_pipeline`
+- `searchgrid.make_union`
+
 Quick Start
 ...........
 
@@ -151,6 +157,16 @@ Searching over multiple grids.
         ...                 reduce=[kbest, pca])
         >>> gs = make_grid_search(pipe)
 
+    And since you no longer care about step names, use
+    `searchgrid.make_pipeline` to express alternative steps even more simply::
+
+        >>> from searchgrid import make_pipeline
+        >>> kbest = set_grid(SelectKBest(), k=[5, 10, 20])
+        >>> pca = set_grid(PCA(), n_components=[5, 10, 20])
+        >>> lr = set_grid(LogisticRegression(), C=[.1, 1, 10])
+        >>> pipe = make_pipeline([kbest, pca], lr)
+        >>> gs = make_grid_search(pipe)
+
 .. |py-versions| image:: https://img.shields.io/pypi/pyversions/searchgrid.svg
     :alt: Python versions supported
 
diff --git a/searchgrid.py b/searchgrid.py
index 753a2a7..f37bc7d 100644
--- a/searchgrid.py
+++ b/searchgrid.py
@@ -1,8 +1,10 @@
 from collections import Mapping as _Mapping
+from collections import defaultdict as _defaultdict
 import itertools as _itertools
 
 from sklearn.model_selection import GridSearchCV as _GridSearchCV
 from sklearn.pipeline import Pipeline as _Pipeline
+from sklearn.pipeline import FeatureUnion as _FeatureUnion
 
 
 def set_grid(estimator, **grid):
@@ -132,3 +134,126 @@ def make_grid_search(estimator, **kwargs):
     """
     estimator = _check_estimator(estimator)
     return _GridSearchCV(estimator, build_param_grid(estimator), **kwargs)
+
+
+def _name_steps(steps, default='alt'):
+    """Generate names for estimators."""
+    steps = [estimators if isinstance(estimators, list) else [estimators]
+             for estimators in steps]
+
+    names = []
+    for estimators in steps:
+        estimators = estimators[:]
+        if len(estimators) > 1:
+            while None in estimators:
+                estimators.remove(None)
+        step_names = {type(estimator).__name__.lower()
+                      for estimator in estimators}
+        if len(step_names) > 1:
+            names.append(default)
+        else:
+            names.append(step_names.pop())
+
+    namecount = _defaultdict(int)
+    for name in names:
+        namecount[name] += 1
+
+    for k, v in list(namecount.items()):
+        if v == 1:
+            del namecount[k]
+
+    for i in reversed(range(len(names))):
+        name = names[i]
+        if name in namecount:
+            names[i] += "-%d" % namecount[name]
+            namecount[name] -= 1
+
+    named_steps = list(zip(names, [step[0] for step in steps]))
+    grid = {k: v for k, v in zip(names, steps) if len(v) > 1}
+    return named_steps, grid
+
+
+def make_pipeline(*steps, **kwargs):
+    """Construct a Pipeline with alternative estimators to search over
+
+    Parameters
+    ----------
+    steps
+        Each step is specified as one of:
+
+        * an estimator instance
+        * None (meaning no transformation)
+        * a list of the above, indicating that a grid search should alternate
+          over the estimators (or None) in the list
+    kwargs
+        Keyword arguments to the constructor of
+        :class:`sklearn.pipeline.Pipeline`.
+
+    Examples
+    --------
+    >>> from sklearn.feature_extraction.text import CountVectorizer
+    >>> from sklearn.feature_extraction.text import TfidfTransformer
+    >>> from sklearn.feature_selection import SelectKBest
+    >>> from sklearn.decomposition import PCA
+    >>> from sklearn.linear_model import LogisticRegression
+    >>> from sklearn.ensemble import RandomForestClassifier
+    >>> from sklearn.model_selection import ParameterGrid
+    >>> from searchgrid import make_pipeline, build_param_grid
+    >>> pipe = make_pipeline(CountVectorizer(),
+    ...                      [TfidfTransformer(), None],
+    ...                      [PCA(n_components=5), SelectKBest(k=5)],
+    ...                      [set_grid(LogisticRegression(),
+    ...                                C=[.1, 1., 10.]),
+    ...                       RandomForestClassifier()])
+    >>> pipe.steps  # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS
+    [('countvectorizer', CountVectorizer(...)),
+     ('tfidftransformer', TfidfTransformer(...)),
+     ('alt-1', PCA(...)),
+     ('alt-2', LogisticRegression(...))]
+    >>> n_combinations = len(ParameterGrid(build_param_grid(pipe)))
+    >>> n_combinations
+    ... # 2 * 2 * (3 + 1)
+    16
+
+    Notes
+    -----
+    Each step is named according to the set of estimator types in its list:
+
+    * if a step has only one type of estimator (disregarding None), it takes
+      that estimator's class name (lowercased)
+    * if a step has estimators of mixed type, the step is named 'alt'
+    * if there are multiple steps of the same name using the above rules,
+      a suffix '-1', '-2', etc. is added.
+    """
+    steps, grid = _name_steps(steps)
+    return set_grid(_Pipeline(steps, **kwargs), **grid)
+
+
+def make_union(*transformers, **kwargs):
+    """Construct a FeatureUnion with alternative estimators to search over
+
+    Parameters
+    ----------
+    steps
+        Each step is specified as one of:
+
+        * an estimator instance
+        * None (meaning no features)
+        * a list of the above, indicating that a grid search should alternate
+          over the estimators (or None) in the list
+    kwargs
+        Keyword arguments to the constructor of
+        :class:`sklearn.pipeline.FeatureUnion`.
+
+    Notes
+    -----
+    Each step is named according to the set of estimator types in its list:
+
+    * if a step has only one type of estimator (disregarding None), it takes
+      that estimator's class name (lowercased)
+    * if a step has estimators of mixed type, the step is named 'alt'
+    * if there are multiple steps of the same name using the above rules,
+      a suffix '-1', '-2', etc. is added.
+    """
+    steps, grid = _name_steps(transformers)
+    return set_grid(_FeatureUnion(steps, **kwargs), **grid)


Test patch:
diff --git a/test_searchgrid.py b/test_searchgrid.py
index 4f31d04..57a4c96 100644
--- a/test_searchgrid.py
+++ b/test_searchgrid.py
@@ -1,10 +1,12 @@
 import pytest
-from sklearn.pipeline import Pipeline, make_pipeline
+from sklearn.pipeline import Pipeline, FeatureUnion
+from sklearn.pipeline import make_pipeline as skl_make_pipeline
 from sklearn.svm import SVC
 from sklearn.linear_model import LogisticRegression, SGDClassifier
-from sklearn.feature_selection import SelectKBest
+from sklearn.feature_selection import SelectKBest, SelectPercentile
 from sklearn.datasets import load_iris
 from searchgrid import set_grid, build_param_grid, make_grid_search
+from searchgrid import make_pipeline, make_union
 
 
 @pytest.mark.parametrize(('estimator', 'param_grid'), [
@@ -12,23 +14,12 @@ from searchgrid import set_grid, build_param_grid, make_grid_search
      {'C': [1, 2]}),
     (set_grid(SVC(), C=[1, 2], gamma=[1, 2]),
      {'C': [1, 2], 'gamma': [1, 2]}),
-    (make_pipeline(set_grid(SVC(), C=[1, 2], gamma=[1, 2])),
+    (skl_make_pipeline(set_grid(SVC(), C=[1, 2], gamma=[1, 2])),
      {'svc__C': [1, 2], 'svc__gamma': [1, 2]}),
 ])
 def test_build_param_grid(estimator, param_grid):
     assert build_param_grid(estimator) == param_grid
 
-#    pytest.mark.xfail(
-#        (set_grid(SVC(), [{'kernel': ['linear']},
-#                          {'kernel': 'rbf', 'gamma': [1, 2]}]),
-#         [{'kernel': ['linear']}, {'kernel': 'rbf', 'gamma': [1, 2]}])),
-#    pytest.mark.xfail(
-#        (make_pipeline(set_grid(SVC(), [{'kernel': ['linear']},
-#                                        {'kernel': ['rbf'],
-#                                         'gamma': [1, 2]}])),
-#         [{'svc__kernel': ['linear']},
-#          {'svc__kernel': 'rbf', 'svc__gamma': [1, 2]}])),
-
 
 def test_build_param_grid_set_estimator():
     clf1 = SVC()
@@ -80,3 +71,42 @@ def test_make_grid_search():
     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]
+
+
+def test_make_pipeline():
+    t1 = SelectKBest()
+    t2 = SelectKBest()
+    t3 = SelectKBest()
+    t4 = SelectKBest()
+    t5 = SelectPercentile()
+    t6 = SelectKBest()
+    t7 = SelectKBest()
+    t8 = SelectKBest()
+    t9 = SelectPercentile()
+    in_steps = [[t1, None],
+                [t2, t3],
+                [t4, t5],  # mixed
+                t6,
+                [None, t7],
+                [t8, None, t9],  # mixed
+                None]
+    pipe = make_pipeline(*in_steps, memory='/path/to/nowhere')
+    union = make_union(*in_steps)
+
+    for est, est_steps in [(pipe, pipe.steps),
+                           (union, union.transformer_list)]:
+        names, steps = zip(*est_steps)
+        assert names == ('selectkbest-1', 'selectkbest-2', 'alt-1',
+                         'selectkbest-3', 'selectkbest-4', 'alt-2', 'nonetype')
+        assert steps == (t1, t2, t4, t6, None, t8, None)
+
+        assert len(est._param_grid) == 5
+        assert est._param_grid[names[0]] == [t1, None]
+        assert est._param_grid[names[1]] == [t2, t3]
+        assert est._param_grid[names[2]] == [t4, t5]
+        assert est._param_grid[names[4]] == [None, t7]
+        assert est._param_grid[names[5]] == [t8, None, t9]
+
+    assert type(pipe) is Pipeline
+    assert type(union) is FeatureUnion
+    assert pipe.memory == '/path/to/nowhere'