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)

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()])
```
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'

pip install -e .
parse_log_pytest
requirements.txt
pytest pytest-cov python-coveralls
3.6
doc/requirements.txt test_requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs==22.2.0
Babel==2.11.0
certifi==2021.5.30
charset-normalizer==2.0.12
coverage==6.2
docutils==0.17.1
flake8==5.0.4
idna==3.10
imagesize==1.4.1
importlib-metadata==4.2.0
iniconfig==1.1.1
Jinja2==3.0.3
joblib==1.1.1
MarkupSafe==2.0.1
mccabe==0.7.0
numpy==1.19.5
numpydoc==1.1.0
packaging==21.3
pluggy==1.0.0
py==1.11.0
pycodestyle==2.9.1
pyflakes==2.5.0
Pygments==2.14.0
pyparsing==3.1.4
pytest==7.0.1
pytest-cov==4.0.0
python-coveralls==2.9.3
pytz==2025.2
PyYAML==6.0.1
requests==2.27.1
scikit-learn==0.24.2
scipy==1.5.4
-e git+https://github.com/jnothman/searchgrid.git@1f1a79f272dceb1afb44ec3228cdab0c52ea9278#egg=searchgrid
six==1.17.0
snowballstemmer==2.2.0
Sphinx==4.3.2
sphinx-issues==3.0.1
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
threadpoolctl==3.1.0
tomli==1.2.3
typing_extensions==4.1.1
urllib3==1.26.20
zipp==3.6.0

name: searchgrid
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - attrs==22.2.0
      - babel==2.11.0
      - charset-normalizer==2.0.12
      - coverage==6.2
      - docutils==0.17.1
      - flake8==5.0.4
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.2.0
      - iniconfig==1.1.1
      - jinja2==3.0.3
      - joblib==1.1.1
      - markupsafe==2.0.1
      - mccabe==0.7.0
      - numpy==1.19.5
      - numpydoc==1.1.0
      - packaging==21.3
      - pluggy==1.0.0
      - py==1.11.0
      - pycodestyle==2.9.1
      - pyflakes==2.5.0
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytest-cov==4.0.0
      - python-coveralls==2.9.3
      - pytz==2025.2
      - pyyaml==6.0.1
      - requests==2.27.1
      - scikit-learn==0.24.2
      - scipy==1.5.4
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==4.3.2
      - sphinx-issues==3.0.1
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - threadpoolctl==3.1.0
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - zipp==3.6.0
prefix: /opt/conda/envs/searchgrid

test_searchgrid.py::test_build_param_grid[estimator0-param_grid0] test_searchgrid.py::test_build_param_grid[estimator1-param_grid1] test_searchgrid.py::test_build_param_grid[estimator2-param_grid2] test_searchgrid.py::test_build_param_grid_set_estimator test_searchgrid.py::test_regression test_searchgrid.py::test_make_grid_search
test_searchgrid.py::test_make_pipeline
README.rst searchgrid.py
README.rst searchgrid.py