Repository: koaning/scikit-lego
Pull request #81 (2019-03-28 09:30:55)

Let RandomAdder only transform train data
Implemented a mixin which allows doing different transformations on your train and test set

MBrouns: Also, I hate the name I currently gave the mixin so I'm definitely open to suggestions there.

Patch:
diff --git a/sklego/common.py b/sklego/common.py
index 3fa4e9e..2ef9e79 100644
--- a/sklego/common.py
+++ b/sklego/common.py
@@ -1,3 +1,89 @@
+import hashlib
+
+import numpy as np
+import pandas as pd
+from sklearn.base import TransformerMixin
+from sklearn.utils.validation import check_is_fitted, check_array, check_X_y
+
+
+class TrainOnlyTransformerMixin(TransformerMixin):
+    """
+    Allows using a separate function for transforming train and test data
+
+    Usage:
+    >>> from sklearn.base import BaseEstimator
+    >>> class TrainOnlyTransformer(TrainOnlyTransformerMixin, BaseEstimator):
+    ...     def fit(self, X, y):
+    ...         super().fit(X, y)
+    ...
+    ...     def transform_train(self, X, y=None):
+    ...          return X + np.random.normal(0, 1, size=X.shape)
+    ...
+    >>> X_train, X_test = np.random.randn(100, 4), np.random.randn(100, 4)
+    >>> y_train, y_test = np.random.randn(100), np.random.randn(100)
+    >>>
+    >>> trf = TrainOnlyTransformer()
+    >>> trf.fit(X_train, y_train)
+    >>>
+    >>> assert np.all(trf.transform(X_train) != X_train)
+    >>> assert np.all(trf.transform(X_test) == X_test)
+
+    Transformers using this class as a mixin should at a minimum:
+     - call `super().fit` in their fit method
+     - implement `transform_train()`
+
+    They may also implement `transform_test()`. If it is not implemented,
+    `transform_test` will simply return the untransformed dataframe
+    """
+
+    HASHERS = {
+        pd.DataFrame: lambda X: hashlib.sha256(pd.util.hash_pandas_object(X, index=True).values).hexdigest(),
+        np.ndarray: lambda X: hash(X.data.tobytes()),
+        np.memmap: lambda X: hash(X.data.tobytes()),
+
+    }
+
+    def fit(self, X, y):
+        """Calculates the hash of X_train"""
+        check_X_y(X, y, estimator=self)
+        self.X_hash_ = self._hash(X)
+        self.dim_ = X.shape[1]
+        return self
+
+    @staticmethod
+    def _hash(X):
+        """Returns a hash of X based on the type of X. Hashers are defined in TrainOnlyMixin.HASHERS"""
+        try:
+            hasher = TrainOnlyTransformerMixin.HASHERS[type(X)]
+        except KeyError:
+            raise ValueError(f'Unknown datatype {type(X)}, '
+                             f'TransformerSelector only supports {TrainOnlyTransformerMixin.HASHERS.keys()}')
+        else:
+            return hasher(X)
+
+    def transform(self, X, y=None):
+        """
+        Dispatcher for transform method.
+
+        It will dispatch to `self.transform_train` if X is the same as X passed to `fit`, otherwise, it will dispatch
+        to `self.trainsform_test`
+        """
+        check_is_fitted(self, ['X_hash_', 'dim_'])
+        check_array(X, estimator=self)
+
+        if X.shape[1] != self.dim_:
+            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self.dim_}')
+
+        if self._hash(X) == self.X_hash_:
+            return self.transform_train(X)
+        else:
+            return self.transform_test(X)
+
+    def transform_train(self, X, y=None):
+        raise NotImplementedError('Subclasses of TrainOnlyMixin should implement `transform_train`')
+
+    def transform_test(self, X, y=None):
+        return X
 
 
 def as_list(val):
diff --git a/sklego/transformers.py b/sklego/transformers.py
index 0f577a7..4d9f593 100644
--- a/sklego/transformers.py
+++ b/sklego/transformers.py
@@ -2,28 +2,27 @@ from sklearn.base import BaseEstimator, TransformerMixin, MetaEstimatorMixin, cl
 from sklearn.utils import check_array, check_X_y
 from sklearn.utils.validation import FLOAT_DTYPES, check_random_state, check_is_fitted
 
+from sklego.common import TrainOnlyTransformerMixin
 
-class RandomAdder(TransformerMixin, BaseEstimator):
+
+class RandomAdder(TrainOnlyTransformerMixin, BaseEstimator):
     def __init__(self, noise=1, random_state=None):
         self.noise = noise
         self.random_state = random_state
 
     def fit(self, X, y):
-
+        super().fit(X, y)
         X, y = check_X_y(X, y, estimator=self, dtype=FLOAT_DTYPES)
         self.dim_ = X.shape[1]
 
         return self
 
-    def transform(self, X):
+    def transform_train(self, X):
         rs = check_random_state(self.random_state)
         check_is_fitted(self, ['dim_'])
 
         X = check_array(X, estimator=self, dtype=FLOAT_DTYPES)
 
-        if X.shape[1] != self.dim_:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self.dim_}')
-
         return X + rs.normal(0, self.noise, size=X.shape)
 
 


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 086debe..ded0118 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -22,7 +22,12 @@ def test_check_estimator(estimator, monkeypatch):
     # These tests can be monkey patched out using the skips dictionary.
     skips = defaultdict(list, {
         RandomAdder: [
-            'check_methods_subset_invariance',  # Since we add noise, the method is not invariant on a subset
+            # Since we add noise, the method is not invariant on a subset
+            'check_methods_subset_invariance',
+            # The transformerselectormixin needs to compute a hash and it can't on a 'NotAnArray'
+            'check_transformer_data_not_an_array',
+            # the next method tries the transformer with lists as X for which we don't have a hashing function
+            'check_transformer_general'
         ],
         RandomRegressor: [
             'check_methods_subset_invariance',  # Since we add noise, the method is not invariant on a subset
diff --git a/tests/test_common/test_transformerselectormixin.py b/tests/test_common/test_transformerselectormixin.py
new file mode 100644
index 0000000..bcbfee6
--- /dev/null
+++ b/tests/test_common/test_transformerselectormixin.py
@@ -0,0 +1,95 @@
+import itertools as it
+
+import numpy as np
+import pandas as pd
+from sklearn.base import BaseEstimator
+from sklearn.model_selection import train_test_split
+from sklearn.pipeline import make_pipeline
+
+from sklego.common import TrainOnlyTransformerMixin
+from tests.conftest import np_types, n_vals, k_vals
+
+
+class TrainOnlyTrainOnlyTransformer(TrainOnlyTransformerMixin, BaseEstimator):
+
+    def fit(self, X, y):
+        super().fit(X, y)
+
+    def transform_train(self, X, y=None):
+        return X + np.random.normal(0, 1, size=X.shape)
+
+
+def test_hash_numpy():
+    """Tests whether the hash function does not produce collisions on np arrays"""
+    hashes = []
+    for n, k, np_type in it.product(n_vals, k_vals, np_types):
+        X = np.random.normal(0, 2, (n, k)).astype(np_type)
+
+        hashes.append(TrainOnlyTransformerMixin._hash(X))
+
+    assert len(hashes) == len(set(hashes))
+
+
+def test_hash_pandas():
+    """Tests whether the hash function does not produce collisions on dataframes"""
+    hashes = []
+    for n, k, np_type in it.product(n_vals, k_vals, np_types):
+        X = pd.DataFrame(np.random.normal(0, 2, (n, k)).astype(np_type))
+
+        hashes.append(TrainOnlyTransformerMixin._hash(X))
+
+    assert len(hashes) == len(set(hashes))
+
+
+def test_bare_trainonlytransformer(random_xy_dataset_regr):
+    """Tests whether the trainonlytransformer will only transform train when used directly"""
+
+    X_train, X_test, y_train, y_test = train_test_split(*random_xy_dataset_regr)
+
+    trf = TrainOnlyTrainOnlyTransformer()
+    trf.fit(X_train, y_train)
+
+    assert np.all(trf.transform(X_train) != X_train)
+    assert np.all(trf.transform(X_test) == X_test)
+
+
+def test_pipeline_trainonlytransformer(random_xy_dataset_regr):
+    """Tests whether the trainonlytransformer will only transform train when used in a pipeline"""
+
+    X_train, X_test, y_train, y_test = train_test_split(*random_xy_dataset_regr)
+
+    trf = make_pipeline(TrainOnlyTrainOnlyTransformer())
+    trf.fit(X_train, y_train)
+
+    assert np.all(trf.transform(X_train) != X_train)
+    assert np.all(trf.transform(X_test) == X_test)
+
+
+def test_bare_trainonlytransformer_pandas(random_xy_dataset_regr):
+    """Tests whether the trainonlytransformer will only transform train when used directly"""
+    X, y = pd.DataFrame(random_xy_dataset_regr[0]), pd.DataFrame(random_xy_dataset_regr[1])
+    X_train, X_test, y_train, y_test = train_test_split(X, y)
+
+    trf = TrainOnlyTrainOnlyTransformer()
+    trf.fit(X_train, y_train)
+
+    assert np.all(trf.transform(X_train) != X_train)
+    assert np.all(trf.transform(X_test) == X_test)
+
+    assert isinstance(trf.transform(X_train), pd.DataFrame)
+    assert isinstance(trf.transform(X_test), pd.DataFrame)
+
+
+def test_pipeline_trainonlytransformer_pandas(random_xy_dataset_regr):
+    """Tests whether the trainonlytransformer will only transform train when used in a pipeline"""
+    X, y = pd.DataFrame(random_xy_dataset_regr[0]), pd.DataFrame(random_xy_dataset_regr[1])
+    X_train, X_test, y_train, y_test = train_test_split(X, y)
+
+    trf = make_pipeline(TrainOnlyTrainOnlyTransformer())
+    trf.fit(X_train, y_train)
+
+    assert np.all(trf.transform(X_train) != X_train)
+    assert np.all(trf.transform(X_test) == X_test)
+
+    assert isinstance(trf.transform(X_train), pd.DataFrame)
+    assert isinstance(trf.transform(X_test), pd.DataFrame)
diff --git a/tests/test_transformers/test_randomadder.py b/tests/test_transformers/test_randomadder.py
index 6b5fbec..082cbb1 100644
--- a/tests/test_transformers/test_randomadder.py
+++ b/tests/test_transformers/test_randomadder.py
@@ -1,4 +1,5 @@
 import numpy as np
+from sklearn.model_selection import train_test_split
 
 from sklego.transformers import RandomAdder
 
@@ -11,3 +12,14 @@ def test_dtype_regression(random_xy_dataset_regr):
 def test_dtype_classification(random_xy_dataset_clf):
     X, y = random_xy_dataset_clf
     assert RandomAdder().fit(X, y).transform(X).dtype == np.float
+
+
+def test_only_transform_train(random_xy_dataset_clf):
+    X, y = random_xy_dataset_clf
+    X_train, X_test, y_train, y_test = train_test_split(X, y)
+
+    random_adder = RandomAdder()
+    random_adder.fit(X_train, y_train)
+
+    assert np.all(random_adder.transform(X_train) != X_train)
+    assert np.all(random_adder.transform(X_test) == X_test)