Repository: scikit-learn/scikit-learn
Pull request: FEA allow unknowns in OrdinalEncoder transform
Sometimes it can be convenient to allow values (categories) in the transform of OrdinalEncoder that were not present in the fit data set. For example, a machine learning method, which is able of setting such an unknown sample of the corresponding feature to a neutral value (i.e. non-informative), could use the information from all other features of the sample and still output a prediction (instead of no prediction at all).

Closes #13488 
Closes #15108
Closes #16959
Closes #14534
Closes #12045
Closes #13897

Problem statement:
Handle Error Policy in OrdinalEncoder
Preprocessor class `OneHotEncoder` allows transformation if unknown values are found. It would be great to introduce the same option to `OrdinalEncoder`. It seems simple to do since `OrdinalEncoder` (as well as `OneHotEncoder`) is derived from `_BaseEncoder` which actually implements handling error policy.

Hints from issue discussion:
Would #12264 meet your use cases? What specific behaviour do you seek? What
use cases?

No, #12264 is not my case but it is desirable too. I would like `OrdinalEncoder` do not throw an exception if it meets an unexpected value. I mean that unexpected value is value which `categories` does not contain.
What would it do rather than throw an error?

> handle_unknown : ‘error’ or ‘ignore’, default=’error’.
>
> Whether to raise an error or ignore if an unknown categorical feature is present during transform (default is to raise). When this parameter is set to ‘ignore’ and an unknown category is encountered during transform, the resulting one-hot encoded columns for this feature will be all zeros. In the inverse transform, an unknown category will be denoted as None.

I could ignore unknown catregiries value as `OneHotEncoder` does. Another possible scenario (say `sentinel`) could replace unknown value with default one which could be specified in `OrdinalEncoder`'s constructor.
The policy of `OnehotEncoder` for unknown categories is to set all the columns to 0. That wouldn't be great for `OrdinalEncoder` since unknown categories and the first one (i.e. zero) would be mixed.

Allowing a fallback category value seems reasonable to me.

@daskol could you please describe your particular use-case that motivates this change?
We discussed the issue with @jorisvandenbossche and I think the sanest strategy would be to have:

- `min_frequency=5` (5 is an example, the default could be 1) to set the threshold to collapse all categories that appear less than 5 times in the training set into a virtual category
- `rare_category="rare_value"` as a parameter to control the name of the virtual category used to map all the rare values. This will be mostly be useful for `inverse_transform` and for consistency if we implement a similar category collapsing option in other categorical variable preprocessors such as `OneHotEncoder` and `ImpactEncoder` / `TargetEncoder` (whatever its name).
- `handle_unknown="treat_as_rare"` that would map any inference-time unknown categories to the integer that is mapped to the virtual `rare_category` (even when its never used on the training set).

IMO `handle_unknown="treat_as_rare"` is the sanest way to handle inference-time unknown values from a statistical point of view.
Personally I find this issue really annoying. At the moment we cannot use `OrdinalEncoder` on data with a long-tailed distribution of categorical variable frequencies in a cross validation loops without triggering the unknown category exception at prediction time.
also see #12153 for something related. I think min_frequency is good, but I also want max_levels or something like that. Basically we could reimplement all the different pruning options we have in CountVectorizer....


If we add a “rare” category to `OrdinalEncoder` it seems like it goes against the “Ordinal” part of the encoder. It assumes the rare or unknown category has the highest (or lowest) value. If we do introduce this, it would be good to document this behavior. 

Ideally, if OrdinalEncoder can handle most of the logic that has to deal with unknown and infrequent categories, OneHotEncoder would need to do less. (I am thinking of composition ie `OneHotEncoder` has a `OrdinalEncoder`)
Hi! I wanted to add that it seems to me that the feature of allowing the encoder to be fit on data that contains some categories, and then applied to data that contains maybe an additional category or two, seems like a common use case for all kinds of categorical data. Even if one category isn't very uncommon, if you're doing a random split for training and validation it's only a matter of time before this error comes up. I like the "min_frequency" solution for its generality, but to (naive) me, it seems too complicated. To me, it seems the default behavior should be to send all categories not present in the original fitting to a single virtual category. Or maybe a "create_virtual_category = True" option. If this is amenable, I'd be happy to take a crack at making it, I'm trying to spend more time working on open source code!

Commit message: only astype(object) call

Patch to review:
diff --git a/sklearn/preprocessing/_encoders.py b/sklearn/preprocessing/_encoders.py
index d8df3b3311783..27456170930c9 100644
--- a/sklearn/preprocessing/_encoders.py
+++ b/sklearn/preprocessing/_encoders.py
@@ -4,6 +4,7 @@
 
 import numpy as np
 from scipy import sparse
+import numbers
 
 from ..base import BaseEstimator, TransformerMixin
 from ..utils import check_array
@@ -115,7 +116,7 @@ def _transform(self, X, handle_unknown='error'):
         for i in range(n_features):
             Xi = X_list[i]
             diff, valid_mask = _check_unknown(Xi, self.categories_[i],
-                                                     return_mask=True)
+                                              return_mask=True)
 
             if not np.all(valid_mask):
                 if handle_unknown == 'error':
@@ -621,6 +622,18 @@ class OrdinalEncoder(_BaseEncoder):
     dtype : number type, default np.float64
         Desired dtype of output.
 
+    handle_unknown : {'error', 'use_encoded_value}', default='error'
+        When set to 'error' an error will be raised in case an unknown
+        categorical feature is present during transform. When set to
+        'use_encoded_value', the encoded value of unknown categories will be
+        set to the value given for the parameter `unknown_value`. In
+        :meth:inverse_transform, an unknown category will be denoted as None.
+
+    unknown_value : int, default=None
+        When the parameter handle_unknown is set to 'use_encoded_value', this
+        parameter is mandatory and will set the encoded value of unknown
+        categories.
+
     Attributes
     ----------
     categories_ : list of arrays
@@ -657,9 +670,12 @@ class OrdinalEncoder(_BaseEncoder):
     """
 
     @_deprecate_positional_args
-    def __init__(self, *, categories='auto', dtype=np.float64):
+    def __init__(self, *, categories='auto', dtype=np.float64,
+                 handle_unknown='error', unknown_value=None):
         self.categories = categories
         self.dtype = dtype
+        self.handle_unknown = handle_unknown
+        self.unknown_value = unknown_value
 
     def fit(self, X, y=None):
         """
@@ -678,8 +694,21 @@ def fit(self, X, y=None):
         -------
         self
         """
+        if self.handle_unknown == 'use_encoded_value':
+            if not isinstance(self.unknown_value, numbers.Integral):
+                raise TypeError(f"Set unknown_value to an integer, got "
+                                f"{self.unknown_value}.")
+
         self._fit(X)
 
+        if self.handle_unknown == 'use_encoded_value':
+            for i in range(len(self.categories_)):
+                if 0 <= self.unknown_value < len(self.categories_[i]):
+                    raise ValueError(f"The used value for unknown_value "
+                                     f"{self.unknown_value} is one of the "
+                                     f"values already used for encoding the "
+                                     f"seen categories.")
+
         return self
 
     def transform(self, X):
@@ -696,7 +725,12 @@ def transform(self, X):
         X_out : sparse matrix or a 2-d array
             Transformed input.
         """
-        X_int, _ = self._transform(X)
+        X_int, X_mask = self._transform(X, handle_unknown=self.handle_unknown)
+
+        # create separate category for unknown values
+        if self.handle_unknown == 'use_encoded_value':
+            for i in range(len(self.categories_)):
+                X_int[~X_mask[:, i], i] = self.unknown_value
         return X_int.astype(self.dtype, copy=False)
 
     def inverse_transform(self, X):
@@ -729,8 +763,24 @@ def inverse_transform(self, X):
         dt = np.find_common_type([cat.dtype for cat in self.categories_], [])
         X_tr = np.empty((n_samples, n_features), dtype=dt)
 
+        found_unknown = {}
+
         for i in range(n_features):
             labels = X[:, i].astype('int64', copy=False)
-            X_tr[:, i] = self.categories_[i][labels]
+            if self.handle_unknown == 'use_encoded_value':
+                unknown_labels = labels == self.unknown_value
+                X_tr[:, i] = self.categories_[i][np.where(
+                    unknown_labels, 0, labels)]
+                found_unknown[i] = unknown_labels
+            else:
+                X_tr[:, i] = self.categories_[i][labels]
+
+        # insert None values for unknown values
+        if found_unknown:
+            if X_tr.dtype != object:
+                X_tr = X_tr.astype(object, copy=False)
+
+            for idx, mask in found_unknown.items():
+                X_tr[mask, idx] = None
 
         return X_tr
diff --git a/sklearn/preprocessing/tests/test_encoders.py b/sklearn/preprocessing/tests/test_encoders.py
index 5bff754fab752..d2cfe8d898556 100644
--- a/sklearn/preprocessing/tests/test_encoders.py
+++ b/sklearn/preprocessing/tests/test_encoders.py
@@ -553,6 +553,59 @@ def test_ordinal_encoder_raise_missing(X):
         ohe.transform(X)
 
 
+def test_ordinal_encoder_handle_unknowns_string():
+    enc = OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-2)
+    X_fit = np.array([['a', 'x'], ['b', 'y'], ['c', 'z']], dtype=object)
+    X_trans = np.array([['c', 'xy'], ['bla', 'y'], ['a', 'x']], dtype=object)
+    enc.fit(X_fit)
+
+    X_trans_enc = enc.transform(X_trans)
+    exp = np.array([[2, -2], [-2, 1], [0, 0]], dtype='int64')
+    assert_array_equal(X_trans_enc, exp)
+
+    X_trans_inv = enc.inverse_transform(X_trans_enc)
+    inv_exp = np.array([['c', None], [None, 'y'], ['a', 'x']], dtype=object)
+    assert_array_equal(X_trans_inv, inv_exp)
+
+
+@pytest.mark.parametrize('dtype', [float, int])
+def test_ordinal_encoder_handle_unknowns_numeric(dtype):
+    enc = OrdinalEncoder(handle_unknown='use_encoded_value',
+                         unknown_value=-999)
+    X_fit = np.array([[1, 7], [2, 8], [3, 9]], dtype=dtype)
+    X_trans = np.array([[3, 12], [23, 8], [1, 7]], dtype=dtype)
+    enc.fit(X_fit)
+
+    X_trans_enc = enc.transform(X_trans)
+    exp = np.array([[2, -999], [-999, 1], [0, 0]], dtype='int64')
+    assert_array_equal(X_trans_enc, exp)
+
+    X_trans_inv = enc.inverse_transform(X_trans_enc)
+    inv_exp = np.array([[3, None], [None, 8], [1, 7]], dtype=object)
+    assert_array_equal(X_trans_inv, inv_exp)
+
+
+def test_ordinal_encoder_handle_unknowns_raise():
+    X = np.array([['a', 'x'], ['b', 'y']], dtype=object)
+
+    enc = OrdinalEncoder(handle_unknown='use_encoded_value')
+    msg = ("Set unknown_value to an integer, got None.")
+    with pytest.raises(TypeError, match=msg):
+        enc.fit(X)
+
+    enc = OrdinalEncoder(handle_unknown='use_encoded_value',
+                         unknown_value='bla')
+    msg = ("Set unknown_value to an integer, got bla.")
+    with pytest.raises(TypeError, match=msg):
+        enc.fit(X)
+
+    enc = OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=1)
+    msg = ("The used value for unknown_value 1 is one of the values already "
+           "used for encoding the seen categories.")
+    with pytest.raises(ValueError, match=msg):
+        enc.fit(X)
+
+
 def test_ordinal_encoder_raise_categories_shape():
 
     X = np.array([['Low', 'Medium', 'High', 'Medium', 'Low']], dtype=object).T


Code review comments:
diff_hunk: @@ -621,6 +622,18 @@ class OrdinalEncoder(_BaseEncoder):
     dtype : number type, default np.float64
         Desired dtype of output.
 
+    handle_unknown : {'error', 'use_encoded_value}', default='error'
+        When set to 'error' an error will be raised in case an unknown
+        categorical feature is present during transform. When set to
+        'use_encoded_value', the encoded value of unknown categories will be
+        set to the value given for the parameter `unknown_value`. In
+        :meth:inverse_transform, an unknown category will be denoted as None.
path: sklearn/preprocessing/_encoders.py
text: @user1:
```suggestion
        :meth:`inverse_transform`, an unknown category will be denoted as None.
```

diff_hunk: @@ -678,8 +694,21 @@ def fit(self, X, y=None):
         -------
         self
         """
+        if self.handle_unknown == 'use_encoded_value':
+            if not isinstance(self.unknown_value, numbers.Integral):
+                raise TypeError(f"Set unknown_value to an integer, got "
path: sklearn/preprocessing/_encoders.py
text: @user1:
Let's change the tone a little:
```suggestion
                raise TypeError(f"unknown_value should be an integer, got "
```

diff_hunk: @@ -621,6 +622,18 @@ class OrdinalEncoder(_BaseEncoder):
     dtype : number type, default np.float64
         Desired dtype of output.
 
+    handle_unknown : {'error', 'use_encoded_value}', default='error'
+        When set to 'error' an error will be raised in case an unknown
+        categorical feature is present during transform. When set to
+        'use_encoded_value', the encoded value of unknown categories will be
+        set to the value given for the parameter `unknown_value`. In
+        :meth:inverse_transform, an unknown category will be denoted as None.
+
+    unknown_value : int, default=None
+        When the parameter handle_unknown is set to 'use_encoded_value', this
+        parameter is mandatory and will set the encoded value of unknown
path: sklearn/preprocessing/_encoders.py
text: @user1:
more familiar language for APIs

```suggestion
        parameter is required and will set the encoded value of unknown
```