Repository: scikit-learn/scikit-learn
Pull request: ColumnTransformer generalization to work on empty lists 
#### Reference Issues/PRs
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
Fixes #12071 


#### What does this implement/fix? Explain your changes.
Column Transformer can handle 'empty' columns. 

#### Any other comments?
main code by @jorisvandenbossche 
test cases by me


Problem statement:
ColumnTransformer requires at least one column for each part it transforms
#### Description
<!-- Example: Joblib Error thrown when calling fit on LatentDirichletAllocation with evaluate_every > 0-->
ColumnTransformer requires at least one column for each part it transforms. This sounds logical, but makes automatic experimentation across datasets with mixed input types hard to apply with a single sklearn model. I would need three separate models for:
* datasets that have only numeric inputs
* datasets that have only categorical inputs
* datasets that have mixed (numeric / categorical) inputs

Of course, this is doable, but it would be extremely convenient to be able to do all this with one sklearn model. 

#### Steps/Code to Reproduce
```
import sklearn
import sklearn.datasets
import sklearn.compose
import sklearn.tree
import sklearn.impute

X, y = sklearn.datasets.fetch_openml('iris', 1, return_X_y=True)

numeric_transformer = sklearn.pipeline.make_pipeline(
    sklearn.preprocessing.Imputer(),
    sklearn.preprocessing.StandardScaler())

categorical_transformer = sklearn.pipeline.make_pipeline(
    sklearn.impute.SimpleImputer(strategy='constant', fill_value='missing'),
    sklearn.preprocessing.OneHotEncoder(handle_unknown='ignore')
)

transformer = sklearn.compose.ColumnTransformer(
    transformers=[
        ('numeric', numeric_transformer, []),
        ('nominal', categorical_transformer, [0,1,2,3])],
    remainder='passthrough')

clf = sklearn.pipeline.make_pipeline(transformer, sklearn.tree.DecisionTreeClassifier())

clf.fit(X, y)
```

#### Expected Results
a fitted model :)

#### Actual Results
```
Traceback (most recent call last):
  File "/home/janvanrijn/projects/sklearn-bot/testjan.py", line 25, in <module>
    clf.fit(X, y)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/pipeline.py", line 265, in fit
    Xt, fit_params = self._fit(X, y, **fit_params)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/pipeline.py", line 230, in _fit
    **fit_params_steps[name])
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/memory.py", line 329, in __call__
    return self.func(*args, **kwargs)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/pipeline.py", line 614, in _fit_transform_one
    res = transformer.fit_transform(X, y, **fit_params)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/compose/_column_transformer.py", line 425, in fit_transform
    result = self._fit_transform(X, y, _fit_transform_one)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/compose/_column_transformer.py", line 371, in _fit_transform
    X=X, fitted=fitted, replace_strings=True))
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/parallel.py", line 983, in __call__
    if self.dispatch_one_batch(iterator):
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/parallel.py", line 825, in dispatch_one_batch
    self._dispatch(tasks)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/parallel.py", line 782, in _dispatch
    job = self._backend.apply_async(batch, callback=cb)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/_parallel_backends.py", line 182, in apply_async
    result = ImmediateResult(func)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/_parallel_backends.py", line 545, in __init__
    self.results = batch()
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/parallel.py", line 261, in __call__
    for func, args, kwargs in self.items]
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/parallel.py", line 261, in <listcomp>
    for func, args, kwargs in self.items]
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/pipeline.py", line 614, in _fit_transform_one
    res = transformer.fit_transform(X, y, **fit_params)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/pipeline.py", line 298, in fit_transform
    Xt, fit_params = self._fit(X, y, **fit_params)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/pipeline.py", line 230, in _fit
    **fit_params_steps[name])
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/externals/joblib/memory.py", line 329, in __call__
    return self.func(*args, **kwargs)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/pipeline.py", line 614, in _fit_transform_one
    res = transformer.fit_transform(X, y, **fit_params)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/base.py", line 462, in fit_transform
    return self.fit(X, y, **fit_params).transform(X)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/preprocessing/imputation.py", line 158, in fit
    force_all_finite=False)
  File "/home/janvanrijn/anaconda3/envs/sklearn-bot/lib/python3.6/site-packages/sklearn/utils/validation.py", line 585, in check_array
    context))
ValueError: Found array with 0 feature(s) (shape=(150, 0)) while a minimum of 1 is required.
```

#### Versions
I just installed the git branch 0.20.X

#### Proposed solution

I can author a PR that checks the column count, or passes through a constant dummy column
<!-- Thanks for contributing! -->

Hints from issue discussion:
@janvanrijn thanks for the feedback!
Yes, I think this is a case that we would want to support. The question is maybe whether it are the other transformers that should support 0 features (it is the imputer that is raising the error), or whether the ColumnTransformer should be smart enough to not pass the data through in this case. 

That second possibility (what you also mention), is certainly the easiest to do. 

I am trying to think of cases where this could give a problem. The fitted `transformers_` would then hold a transformer that is actually not fitted. This introduces some inconsistency, but not sure if that would give problems in practice.

cc @jnothman 
My 2 cents

When I read this it feels like ColumnTransformer should see the empty list
and not even attempt to use the numeric transformer. Kind of like when you
do "`for i in []`.

transformer = sklearn.compose.ColumnTransformer(
    transformers=[
        ('numeric', numeric_transformer, []),
        ('nominal', categorical_transformer, [1,2,3,4])],
    remainder='passthrough')





On Fri, Sep 14, 2018, 3:15 AM Joris Van den Bossche <
notifications@github.com> wrote:

> @janvanrijn <https://github.com/janvanrijn> thanks for the feedback!
> Yes, I think this is a case that we would want to support. The question is
> maybe whether it are the other transformers that should support 0 features
> (it is the imputer that is raising the error), or whether the
> ColumnTransformer should be smart enough to not pass the data through in
> this case.
>
> That second possibility (what you also mention), is certainly the easiest
> to do.
>
> I am trying to think of cases where this could give a problem. The fitted
> transformers_ would then hold a transformer that is actually not fitted.
> This introduces some inconsistency, but not sure if that would give
> problems in practice.
>
> cc @jnothman <https://github.com/jnothman>
>
> —
> You are receiving this because you are subscribed to this thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/scikit-learn/scikit-learn/issues/12071#issuecomment-421254487>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/ADmCPMmz9AKuDyCJMpfjwqKxaXthBGJ5ks5ua1eugaJpZM4WoRWh>
> .
>

I'm in favor of ignoring empty feature lists. I think many transformers will require >0 features.

do we need to store the transformer in ``transformers_``?
> do we need to store the transformer in transformers_?

I would do that, otherwise the length and index into the original passed `transformers` and the fitted `transformers_` attribute gets out of sync, which doesn't seem ideal?
hmmm good point.
> Yes, I think this is a case that we would want to support. 

Awesome.

Is there somewhere an extensive list of all transformers available? I could contribute a set of unit tests this afternoon to start either solution. 
@janvanrijn you can get one from ``all_estimators`` and if you want to add unit tests you could do that in the common tests that already have all the infrastructure to test something with all estimators. Not sure that's necessary here though.
@amueller just out of curiosity, why won't that be necessary? 

Whichever of the proposed solutions is going to take only several lines of code, whereas the real trick is to properly test it (IMO). 
> whereas the real trick is to properly test it (IMO).

Yes, but when doing this in the ColumnTransformer, we don't need to test it with every possible transformer, only testing that the transformer is not called in case there are no features is enough.
Don't have further time this until next week, but this small patch seems to work (at least for the basics):

```patch
--- a/sklearn/compose/_column_transformer.py
+++ b/sklearn/compose/_column_transformer.py
@@ -234,6 +234,8 @@ boolean mask array or callable
                         check_inverse=False)
                 elif trans == 'drop':
                     continue
+                elif hasattr(column, '__len__') and len(column) == 0:
+                    continue
 
             yield (name, trans, sub, get_weight(name))
 
@@ -335,6 +337,8 @@ boolean mask array or callable
                 # so get next transformer, but save original string
                 next(transformers)
                 trans = 'passthrough'
+            elif hasattr(column, '__len__') and len(column) == 0:
+                trans = old
             else:
                 trans = next(transformers)
             transformers_.append((name, trans, column))
```

if we are OK with `transformers_` containing unfitted transformers

Commit message: incorporated comments by Roman and Joel

Patch to review:
diff --git a/sklearn/compose/_column_transformer.py b/sklearn/compose/_column_transformer.py
index e09d2d09d7e43..61f6a427a9920 100644
--- a/sklearn/compose/_column_transformer.py
+++ b/sklearn/compose/_column_transformer.py
@@ -109,8 +109,10 @@ class ColumnTransformer(_BaseComposition, TransformerMixin):
     transformers_ : list
         The collection of fitted transformers as tuples of
         (name, fitted_transformer, column). `fitted_transformer` can be an
-        estimator, 'drop', or 'passthrough'. If there are remaining columns,
-        the final element is a tuple of the form:
+        estimator, 'drop', or 'passthrough'. Note that the column list is
+        allowed to be empty. In that case the transformers will not be fitted.
+        If there are remaining columns, the final element is a tuple of the
+        form:
         ('remainder', transformer, remaining_columns) corresponding to the
         ``remainder`` parameter. If there are remaining columns, then
         ``len(transformers_)==len(transformers)+1``, otherwise
@@ -234,6 +236,8 @@ def _iter(self, X=None, fitted=False, replace_strings=False):
                         check_inverse=False)
                 elif trans == 'drop':
                     continue
+                elif hasattr(column, '__len__') and len(column) == 0:
+                    continue
 
             yield (name, trans, sub, get_weight(name))
 
@@ -335,6 +339,8 @@ def _update_fitted_transformers(self, transformers):
                 # so get next transformer, but save original string
                 next(transformers)
                 trans = 'passthrough'
+            elif hasattr(column, '__len__') and len(column) == 0:
+                trans = 'empty'
             else:
                 trans = next(transformers)
             transformers_.append((name, trans, column))
diff --git a/sklearn/compose/tests/test_column_transformer.py b/sklearn/compose/tests/test_column_transformer.py
index f67806a52c543..476c4e23e7ad4 100644
--- a/sklearn/compose/tests/test_column_transformer.py
+++ b/sklearn/compose/tests/test_column_transformer.py
@@ -185,6 +185,13 @@ def test_column_transformer_dataframe():
     assert len(ct.transformers_) == 2
     assert ct.transformers_[-1][0] != 'remainder'
 
+    ct = ColumnTransformer([('trans1', Trans(), ['first', 'second']),
+                            ('trans2', Trans(), [])])
+    assert_array_equal(ct.fit_transform(X_df), X_res_both)
+    assert_array_equal(ct.fit(X_df).transform(X_df), X_res_both)
+    assert len(ct.transformers_) == 2
+    assert ct.transformers_[-1][0] != 'remainder'
+
     ct = ColumnTransformer([('trans1', Trans(), [0]),
                             ('trans2', Trans(), [1])])
     assert_array_equal(ct.fit_transform(X_df), X_res_both)
@@ -251,6 +258,38 @@ def transform(self, X, y=None):
     assert_array_equal(ct.transformers_[-1][2], [1])
 
 
+def test_column_transformer_empty_columns():
+    # test case that ensures that the column transformer does also work when
+    # a given transformer doesn't have any columns to work on
+    X_array = np.array([[0, 1, 2], [2, 4, 6]]).T
+    X_res_both = X_array
+
+    ct = ColumnTransformer([('trans1', Trans(), [0, 1]),
+                            ('trans2', Trans(), [])])
+    assert_array_equal(ct.fit_transform(X_array), X_res_both)
+    assert_array_equal(ct.fit(X_array).transform(X_array), X_res_both)
+    assert len(ct.transformers_) == 2
+
+    ct = ColumnTransformer([('trans1', Trans(), []),
+                            ('trans2', Trans(), [0, 1])])
+    assert_array_equal(ct.fit_transform(X_array), X_res_both)
+    assert_array_equal(ct.fit(X_array).transform(X_array), X_res_both)
+    assert len(ct.transformers_) == 2
+
+    ct = ColumnTransformer([('trans', Trans(), [])],
+                           remainder='passthrough')
+    assert_array_equal(ct.fit_transform(X_array), X_res_both)
+    assert_array_equal(ct.fit(X_array).transform(X_array), X_res_both)
+    assert len(ct.transformers_) == 2 # including remainder
+
+    fixture = np.array([[],[],[]])
+    ct = ColumnTransformer([('trans', Trans(), [])],
+                           remainder='drop')
+    assert_array_equal(ct.fit_transform(X_array), fixture)
+    assert_array_equal(ct.fit(X_array).transform(X_array), fixture)
+    assert len(ct.transformers_) == 2 # including remainder
+
+
 def test_column_transformer_sparse_array():
     X_sparse = sparse.eye(3, 2).tocsr()
 


Code review comments:
diff_hunk: @@ -234,6 +236,8 @@ def _iter(self, X=None, fitted=False, replace_strings=False):
                         check_inverse=False)
                 elif trans == 'drop':
                     continue
+                elif hasattr(column, '__len__') and len(column) == 0:
path: sklearn/compose/_column_transformer.py
text: @user1:
```py
>>> len(np.zeros((10, 0)))
10
```

Isn't that a problem in this context? Use `.size`?

@user2:
`columns` should not be multi-dimensional, and `.size` is not available for lists. And we can also pass a single scalar (int or string), so the `hasattr(column, '__len__')` was actually a test for `column` being a Sequence. Maybe that would be better done with using `isinstance(.., collections.Sequence)`?

But, now I think about it, we should also catch the case of a boolean array of all False I think, for which the check of zero length will also not work.