Repository: scikit-learn-contrib/category_encoders (language: python)

Issue:
Replace broken link in LeaveOneOutEncoder docs
## Expected Behavior
Working link to further readings on how the leave-on-out-encoder works

## Actual Behavior
404 Error on kaggle page. It seems that the whole competition was removed.

## Steps to Reproduce the Problem

Klick link.

## Solutions

Use either
https://pkghosh.wordpress.com/2018/06/18/leave-one-out-encoding-for-categorical-feature-variables-on-spark/
or 
https://datascience.stackexchange.com/questions/10839/what-is-difference-between-one-hot-encoding-and-leave-one-out-encoding

Unfortunately a paper cannot be found.
All encoders should be recyclable
Currently, when we rerun `fit()` on a trained encoder, the encoder does not get retrained. 

The only notable exception is Leave One Out encoder (thanks to work of @JohnnyC08):
```python    
import unittest

def test_recyclability(self):
     for encoder_name in encoders.__all__:
         with self.subTest(encoder_name=encoder_name):
             # when we use the same encoder on two different datasets, it should not explode
             X_a = pd.DataFrame(data=['1', '2', '2', '2', '2', '2'], columns=['col_a'])
             X_b = pd.DataFrame(data=['1', '1', '1', '2', '2', '2'], columns=['col_b']) # different values and name
             y_dummy = [True, False, True, False, True, False]
             enc = getattr(encoders, encoder_name)()
             enc.fit(X_a, y_dummy)
             enc.fit(X_b, y_dummy)
             verify_numeric(enc.transform(X_b))
```


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 63a1ec76..b25edd5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -56,6 +56,7 @@ coverage.xml
 
 # Sphinx documentation
 docs/_build/
+docs/build/
 
 # PyBuilder
 target/
@@ -68,4 +69,4 @@ runtest.py
 
 *~
 *.swp
-*.swo
\ No newline at end of file
+*.swo
diff --git a/README.md b/README.md
index aa8b5ffd..980484ae 100644
--- a/README.md
+++ b/README.md
@@ -137,7 +137,7 @@ References
  1. Kilian Weinberger; Anirban Dasgupta; John Langford; Alex Smola; Josh Attenberg (2009). Feature Hashing for Large Scale Multitask Learning. Proc. ICML.
  2. Contrast Coding Systems for categorical variables.  UCLA: Statistical Consulting Group. From https://stats.idre.ucla.edu/r/library/r-library-contrast-coding-systems-for-categorical-variables/.
  3. Gregory Carey (2003). Coding Categorical Variables. From http://psych.colorado.edu/~carey/Courses/PSYC5741/handouts/Coding%20Categorical%20Variables%202006-03-03.pdf
- 4. Strategies to encode categorical variables with many categories. From https://www.kaggle.com/c/caterpillar-tube-pricing/discussion/15748#143154.
+ 4. Owen Zhang - Leave One Out Encoding. From https://datascience.stackexchange.com/questions/10839/what-is-difference-between-one-hot-encoding-and-leave-one-out-encoding
  5. Beyond One-Hot: an exploration of categorical variables. From http://www.willmcginnis.com/2015/11/29/beyond-one-hot-an-exploration-of-categorical-variables/
  6. BaseN Encoding and Grid Search in categorical variables. From http://www.willmcginnis.com/2016/12/18/basen-encoding-grid-search-category_encoders/
  7. Daniele Miccii-Barreca (2001). A Preprocessing Scheme for High-Cardinality Categorical Attributes in Classification and Prediction Problems. SIGKDD Explor. Newsl. 3, 1. From http://dx.doi.org/10.1145/507533.507538
diff --git a/category_encoders/backward_difference.py b/category_encoders/backward_difference.py
index 545e82d8..299320ca 100644
--- a/category_encoders/backward_difference.py
+++ b/category_encoders/backward_difference.py
@@ -1,7 +1,6 @@
 """Backward difference contrast encoding"""
 
 import pandas as pd
-from sklearn.base import BaseEstimator, TransformerMixin
 from patsy.contrasts import Diff
 import numpy as np
 from category_encoders.ordinal import OrdinalEncoder
@@ -10,7 +9,7 @@
 __author__ = 'willmcginnis'
 
 
-class BackwardDifferenceEncoder(BaseEstimator, TransformerMixin):
+class BackwardDifferenceEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
     """Backward difference contrast coding for encoding categorical variables.
 
     Parameters
@@ -82,58 +81,17 @@ class BackwardDifferenceEncoder(BaseEstimator, TransformerMixin):
     http://psych.colorado.edu/~carey/Courses/PSYC5741/handouts/Coding%20Categorical%20Variables%202006-03-03.pdf
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_N_UNIQUE
 
     def __init__(self, verbose=0, cols=None, mapping=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value'):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = mapping
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
-        self.cols = cols
         self.ordinal_encoder = None
-        self._dim = None
-        self.feature_names = None
-
-    def fit(self, X, y=None, **kwargs):
-        """Fits an ordinal encoder to produce a consistent mapping across applications and optionally finds
-        generally invariant columns to drop consistently.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # if the input dataset isn't already a dataframe, convert it to one (using default column names)
-        # first check the type
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
 
+    def _fit(self, X, y=None, **kwargs):
         # train an ordinal pre-encoder
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
@@ -155,70 +113,14 @@ def fit(self, X, y=None, **kwargs):
 
         self.mapping = mappings_out
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # drop all output columns with 0 variance.
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
-    def transform(self, X, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # first check the type
-        X = util.convert_input(X)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X) -> pd.DataFrame:
         X = self.ordinal_encoder.transform(X)
-
         if self.handle_unknown == 'error':
             if X[self.cols].isin([-1]).any().any():
                 raise ValueError('Columns to be encoded can not contain new values')
 
         X = self.backward_difference_coding(X, mapping=self.mapping)
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     @staticmethod
     def fit_backward_difference_coding(col, values, handle_missing, handle_unknown):
@@ -274,20 +176,3 @@ def backward_difference_coding(X_in, mapping):
         cols = ['intercept'] + cols
 
         return X.reindex(columns=cols)
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-
-        if not isinstance(self.feature_names, list):
-            raise ValueError('Must fit data first. Affected feature names are not known before.')
-        else:
-            return self.feature_names
diff --git a/category_encoders/basen.py b/category_encoders/basen.py
index b0f691ef..69f4863e 100644
--- a/category_encoders/basen.py
+++ b/category_encoders/basen.py
@@ -2,9 +2,7 @@
 
 import pandas as pd
 import numpy as np
-import math
 import re
-from sklearn.base import BaseEstimator, TransformerMixin
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
 import warnings
@@ -33,7 +31,7 @@ def _ceillogint(n, base):
     return ret
 
 
-class BaseNEncoder(BaseEstimator, TransformerMixin):
+class BaseNEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
     """Base-N encoder encodes the categories into arrays of their base-N representation.  A base of 1 is equivalent to
     one-hot encoding (not really base-1, but useful), a base of 2 is equivalent to binary encoding. N=number of actual
     categories is equivalent to vanilla ordinal encoding.
@@ -98,57 +96,18 @@ class BaseNEncoder(BaseEstimator, TransformerMixin):
 
     """
 
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.N_TO_M
+
     def __init__(self, verbose=0, cols=None, mapping=None, drop_invariant=False, return_df=True, base=2,
                  handle_unknown='value', handle_missing='value'):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
-        self.cols = cols
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = mapping
         self.ordinal_encoder = None
-        self._dim = None
         self.base = base
-        self._encoded_columns = None
-        self.feature_names = None
-
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # if the input dataset isn't already a dataframe, convert it to one (using default column names)
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
 
+    def _fit(self, X, y=None, **kwargs):
         # train an ordinal pre-encoder
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
@@ -160,24 +119,6 @@ def fit(self, X, y=None, **kwargs):
 
         self.mapping = self.fit_base_n_encoding(X)
 
-        # do a transform on the training data to get a column list
-        X_temp = self.transform(X, override_return_df=True)
-        self._encoded_columns = X_temp.columns.values
-        self.feature_names = list(X_temp.columns)
-
-        # drop all output columns with 0 variance.
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
     def fit_base_n_encoding(self, X):
         mappings_out = []
 
@@ -210,39 +151,7 @@ def fit_base_n_encoding(self, X):
 
         return mappings_out
 
-    def transform(self, X, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # first check the type
-        X = util.convert_input(X)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X):
         X_out = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -250,18 +159,7 @@ def transform(self, X, override_return_df=False):
                 raise ValueError('Columns to be encoded can not contain new values')
 
         X_out = self.basen_encode(X_out, cols=self.cols)
-
-        if self.drop_invariant:
-            X_out = X_out.drop(columns=self.drop_cols)
-
-        # impute missing values only in the generated columns
-        # generated_cols = util.get_generated_cols(X, X_out, self.cols)
-        # X_out[generated_cols] = X_out[generated_cols].fillna(value=0.0)
-
-        if self.return_df or override_return_df:
-            return X_out
-        else:
-            return X_out.values
+        return X_out
 
     def inverse_transform(self, X_in):
         """
@@ -306,7 +204,7 @@ def inverse_transform(self, X_in):
                 for col in self.cols:
                     if X[switch.get('col')].isnull().any():
                         warnings.warn("inverse_transform is not supported because transform impute "
-                                      "the unknown category nan when encode %s" % (col,))
+                                      f"the unknown category nan when encode {col}")
 
         return X if self.return_df else X.values
 
@@ -415,20 +313,3 @@ def number_to_base(n, b, limit):
             n, _ = divmod(n, b)
 
         return digits[::-1]
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-
-        if not isinstance(self.feature_names, list):
-            raise ValueError('Must fit data first. Affected feature names are not known before.')
-        else:
-            return self.feature_names
diff --git a/category_encoders/binary.py b/category_encoders/binary.py
index f6950dc7..1db3748a 100644
--- a/category_encoders/binary.py
+++ b/category_encoders/binary.py
@@ -1,14 +1,12 @@
 """Binary encoding"""
-
-import pandas as pd
-from sklearn.base import BaseEstimator, TransformerMixin
-
-import category_encoders as ce
+from functools import partialmethod
+from category_encoders import utils
+from category_encoders.basen import BaseNEncoder
 
 __author__ = 'willmcginnis'
 
 
-class BinaryEncoder(BaseEstimator, TransformerMixin):
+class BinaryEncoder(BaseNEncoder):
     """Binary encoding for categorical variables, similar to onehot, but stores categories as binary bitstrings.
 
     Parameters
@@ -68,88 +66,5 @@ class BinaryEncoder(BaseEstimator, TransformerMixin):
     None
 
     """
-
-    def __init__(self, verbose=0, cols=None, mapping=None, drop_invariant=False, return_df=True,
-                 handle_unknown='value', handle_missing='value'):
-        self.verbose = verbose
-        self.cols = cols
-        self.mapping = mapping
-        self.drop_invariant = drop_invariant
-        self.return_df = return_df
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
-        self.base_n_encoder = ce.BaseNEncoder(base=2, verbose=self.verbose, cols=self.cols, mapping=self.mapping,
-                                              drop_invariant=self.drop_invariant, return_df=self.return_df,
-                                              handle_unknown=self.handle_unknown, handle_missing=self.handle_missing)
-
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        self.base_n_encoder.fit(X, y, **kwargs)
-
-        return self
-
-    def transform(self, X, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        return self.base_n_encoder.transform(X)
-
-    def inverse_transform(self, X_in):
-        """
-        Perform the inverse transformation to encoded data.
-
-        Parameters
-        ----------
-        X_in : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-        p: array, the same size of X_in
-
-        """
-
-        return self.base_n_encoder.inverse_transform(X_in)
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-
-        return self.base_n_encoder.get_feature_names()
+    encoding_relation = utils.EncodingRelation.ONE_TO_M
+    __init__ = partialmethod(BaseNEncoder.__init__, base=2)
diff --git a/category_encoders/cat_boost.py b/category_encoders/cat_boost.py
index 950b5abf..06e4a4b6 100644
--- a/category_encoders/cat_boost.py
+++ b/category_encoders/cat_boost.py
@@ -2,14 +2,13 @@
 
 import numpy as np
 import pandas as pd
-from sklearn.base import BaseEstimator
 import category_encoders.utils as util
 from sklearn.utils.random import check_random_state
 
 __author__ = 'Jan Motl'
 
 
-class CatBoostEncoder(BaseEstimator, util.TransformerWithTargetMixin):
+class CatBoostEncoder(util.BaseEncoder, util.SupervisedTransformerMixin):
     """CatBoost Encoding for categorical features.
 
     Supported targets: binomial and continuous. For polynomial target support, see PolynomialWrapper.
@@ -90,159 +89,26 @@ class CatBoostEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     https://arxiv.org/abs/1706.09516
 
     """
+    prefit_ordinal = False
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value', random_state=None, sigma=None, a=1):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
-        self.use_default_cols = cols is None  # if True, even a repeated call of fit() will select string columns from X
-        self.cols = cols
-        self._dim = None
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self._mean = None
         self.random_state = random_state
         self.sigma = sigma
-        self.feature_names = None
         self.a = a
 
-    def fit(self, X, y, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # unite the input into pandas types
-        X, y = util.convert_inputs(X, y)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.use_default_cols:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        categories = self._fit(
-            X, y,
-            cols=self.cols
-        )
-        self.mapping = categories
-
-        X_temp = self.transform(X, y, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples] when transform by leave one out
-            None, when transform without target information (such as transform test set)
-
-
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # unite the input into pandas types
-        X, y = util.convert_inputs(X, y)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-        X = self._transform(
-            X, y,
-            mapping=self.mapping
-        )
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
-
-    def _fit(self, X_in, y, cols=None):
-        X = X_in.copy(deep=True)
-
-        if cols is None:
-            cols = X.columns.values
+    def _fit(self, X, y, **kwargs):
+        X = X.copy(deep=True)
 
         self._mean = y.mean()
+        self.mapping = {col: self._fit_column_map(X[col], y) for col in self.cols}
 
-        return {col: self._fit_column_map(X[col], y) for col in cols}
-
-    def _fit_column_map(self, series, y):
-        category = pd.Categorical(series)
-
-        categories = category.categories
-        codes = category.codes.copy()
-
-        codes[codes == -1] = len(categories)
-        categories = np.append(categories, np.nan)
-
-        return_map = pd.Series(dict([(code, category) for code, category in enumerate(categories)]))
-
-        result = y.groupby(codes).agg(['sum', 'count'])
-        return result.rename(return_map)
-
-    def _transform(self, X_in, y, mapping=None):
-        """
-        The model uses a single column of floats to represent the means of the target variables.
-        """
-        X = X_in.copy(deep=True)
+    def _transform(self, X, y=None):
         random_state_ = check_random_state(self.random_state)
 
         # Prepare the data
@@ -250,14 +116,14 @@ def _transform(self, X_in, y, mapping=None):
             # Convert bools to numbers (the target must be summable)
             y = y.astype('double')
 
-        for col, colmap in mapping.items():
+        for col, colmap in self.mapping.items():
             level_notunique = colmap['count'] > 1
 
             unique_train = colmap.index
-            unseen_values = pd.Series([x for x in X_in[col].unique() if x not in unique_train], dtype=unique_train.dtype)
+            unseen_values = pd.Series([x for x in X[col].unique() if x not in unique_train], dtype=unique_train.dtype)
 
-            is_nan = X_in[col].isnull()
-            is_unknown_value = X_in[col].isin(unseen_values.dropna().astype(object))
+            is_nan = X[col].isnull()
+            is_unknown_value = X[col].isin(unseen_values.dropna().astype(object))
 
             if self.handle_unknown == 'error' and is_unknown_value.any():
                 raise ValueError('Columns to be encoded can not contain new values')
@@ -286,7 +152,11 @@ def _transform(self, X_in, y, mapping=None):
                 X.loc[is_unknown_value, col] = np.nan
 
             if self.handle_missing == 'value':
-                X.loc[is_nan & unseen_values.isnull().any(), col] = self._mean
+                # only set value if there are actually missing values.
+                # In case of pd.Categorical columns setting values that are not seen in pd.Categorical gives an error.
+                nan_cond = is_nan & unseen_values.isnull().any()
+                if nan_cond.any():
+                    X.loc[nan_cond, col] = self._mean
             elif self.handle_missing == 'return_nan':
                 X.loc[is_nan, col] = np.nan
 
@@ -295,19 +165,21 @@ def _transform(self, X_in, y, mapping=None):
 
         return X
 
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
+    def _more_tags(self):
+        tags = super()._more_tags()
+        tags["predict_depends_on_y"] = True
+        return tags
 
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
+    def _fit_column_map(self, series, y):
+        category = pd.Categorical(series)
 
-        """
+        categories = category.categories
+        codes = category.codes.copy()
 
-        if not isinstance(self.feature_names, list):
-            raise ValueError('Must fit data first. Affected feature names are not known before.')
-        else:
-            return self.feature_names
+        codes[codes == -1] = len(categories)
+        categories = np.append(categories, np.nan)
+
+        return_map = pd.Series(dict(enumerate(categories)))
+
+        result = y.groupby(codes).agg(['sum', 'count'])
+        return result.rename(return_map)
diff --git a/category_encoders/count.py b/category_encoders/count.py
index 151792f2..f532e50d 100644
--- a/category_encoders/count.py
+++ b/category_encoders/count.py
@@ -1,19 +1,18 @@
 """Count Encoder"""
-from __future__ import division
-
 import numpy as np
 import pandas as pd
 import category_encoders.utils as util
 from category_encoders.ordinal import OrdinalEncoder
 
 from copy import copy
-from sklearn.base import BaseEstimator, TransformerMixin
 
 
 __author__ = 'joshua t. dunn'
 
 
-class CountEncoder(BaseEstimator, TransformerMixin):
+class CountEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(self, verbose=0, cols=None, drop_invariant=False,
                  return_df=True, handle_unknown='value',
@@ -106,20 +105,13 @@ def __init__(self, verbose=0, cols=None, drop_invariant=False,
         ----------
 
         """
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
-        self.cols = cols
-        self._dim = None
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self.normalize = normalize
         self.min_group_size = min_group_size
         self.min_group_name = min_group_name
         self.combine_min_nan_groups = combine_min_nan_groups
-        self.feature_names = None
         self.ordinal_encoder = None
 
         self._check_set_create_attrs()
@@ -132,34 +124,7 @@ def __init__(self, verbose=0, cols=None, drop_invariant=False,
         self._handle_unknown = {}
         self._handle_missing = {}
 
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X.
-
-        Parameters
-        ----------
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-        self : encoder
-            Returns self.
-        """
-
-        # first check the type
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
+    def _fit(self, X, y=None, **kwargs):
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
             cols=self.cols,
@@ -168,72 +133,39 @@ def fit(self, X, y=None, **kwargs):
         )
         self.ordinal_encoder = self.ordinal_encoder.fit(X)
         X_ordinal = self.ordinal_encoder.transform(X)
-        self._check_set_create_dict_attrs()
 
+        self._check_set_create_dict_attrs()
         self._fit_count_encode(X_ordinal, y)
-
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = list(X_temp.columns)
-
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [
-                x for x in generated_cols if X_temp[x].var() <= 10e-5
-            ]
-
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
         return self
 
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples]
-            
-        Returns
-        -------
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-        """
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError(
-                'Must train encoder before it can be used to transform data.'
-            )
-
-        # first check the type
-        X = util.convert_input(X)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(
-                'Unexpected input dimension %d, expected %d'
-                % (X.shape[1], self._dim,)
-            )
+    def _transform(self, X):
+        for col in self.cols:
+            # Treat None as np.nan
+            X[col] = pd.Series([el if el is not None else np.NaN for el in X[col]], index=X[col].index)
+            if self.handle_missing == "value":
+                if not util.is_category(X[col].dtype):
+                    X[col] = X[col].fillna(np.nan)
 
-        if not list(self.cols):
-            return X
+            if self._min_group_size is not None:
+                if col in self._min_group_categories.keys():
+                    X[col] = X[col].map(self._min_group_categories[col]).fillna(X[col])
 
-        X, _ = self._transform_count_encode(X, y)
+            X[col] = X[col].astype(object).map(self.mapping[col])
+            if isinstance(self._handle_unknown[col], (int, np.integer)):
+                X[col] = X[col].fillna(self._handle_unknown[col])
 
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
+            elif (self._handle_unknown[col] == 'value'
+                  and X[col].isna().any()
+                  and self._handle_missing[col] != 'return_nan'
+            ):
+                X[col].replace(np.nan, 0, inplace=True)
 
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+            elif (
+                    self._handle_unknown[col] == 'error'
+                    and X[col].isnull().any()
+            ):
+                raise ValueError(f'Missing data found in column {col} at transform time.')
+        return X
 
     def _fit_count_encode(self, X_in, y):
         """Perform the count encoding."""
@@ -260,39 +192,6 @@ def _fit_count_encode(self, X_in, y):
         if any([val is not None for val in self._min_group_size.values()]):
             self.combine_min_categories(X)
 
-    def _transform_count_encode(self, X_in, y):
-        """Perform the transform count encoding."""
-        X = X_in.copy(deep=True)
-
-        for col in self.cols:
-            # Treat None as np.nan
-            X[col] = pd.Series([el if el is not None else np.NaN for el in X[col]], index=X[col].index)
-            if self.handle_missing == "value":
-                if not util.is_category(X[col].dtype):
-                    X[col] = X[col].fillna(np.nan)
-
-            if self._min_group_size is not None:
-                if col in self._min_group_categories.keys():
-                    X[col] = X[col].map(self._min_group_categories[col]).fillna(X[col])
-            
-            X[col] = X[col].astype(object).map(self.mapping[col])
-            if isinstance(self._handle_unknown[col], (int, np.integer)):
-                X[col] = X[col].fillna(self._handle_unknown[col])
-            
-            elif (self._handle_unknown[col] == 'value'
-                    and X[col].isna().any()
-                    and self._handle_missing[col] != 'return_nan'
-                 ):
-                 X[col].replace(np.nan, 0, inplace=True)
- 
-            elif (
-                self._handle_unknown[col] == 'error'
-                and X[col].isnull().any()
-            ):
-                raise ValueError(f'Missing data found in column {col} at transform time.')
-
-        return X, self.mapping
-
     def combine_min_categories(self, X):
         """Combine small categories into a single category."""
         for col, mapper in self.mapping.items():
@@ -417,42 +316,17 @@ def _check_set_create_dict_attrs(self):
             ):
                 raise ValueError(
                     "Cannot have `handle_missing` == 'return_nan' and "
-                    "'combine_min_nan_groups' == 'force' for columns `%s`."
-                    % (col,)
+                    f"'combine_min_nan_groups' == 'force' for columns `{col}`."
                 )
             
             if (
                 self._combine_min_nan_groups[col] is not True
                 and self._min_group_size[col] is None
             ):
-                raise ValueError(
-                    "`combine_min_nan_groups` only works when `min_group_size`"
-                    "is set for column %s."
-                    % (col,)
-                )
+                raise ValueError(f"`combine_min_nan_groups` only works when `min_group_size` is set for column {col}.")
 
             if (
                 self._min_group_name[col] is not None
                 and self._min_group_size[col] is None
             ):
-                raise ValueError(
-                    "`min_group_name` only works when `min_group_size`"
-                    "is set for column %s."
-                    % (col,)
-                )
-    
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-        if not isinstance(self.feature_names, list):
-            raise ValueError("CountEncoder has to be fitted to return feature names.")
-        else:
-            return self.feature_names
+                raise ValueError(f"`min_group_name` only works when `min_group_size` is set for column {col}.")
diff --git a/category_encoders/glmm.py b/category_encoders/glmm.py
index 84a1c8d9..bf72efc1 100644
--- a/category_encoders/glmm.py
+++ b/category_encoders/glmm.py
@@ -3,7 +3,6 @@
 import re
 import numpy as np
 import pandas as pd
-from sklearn.base import BaseEstimator
 from sklearn.utils.random import check_random_state
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
@@ -13,18 +12,20 @@
 __author__ = 'Jan Motl'
 
 
-class GLMMEncoder(BaseEstimator, util.TransformerWithTargetMixin):
+class GLMMEncoder(util.BaseEncoder, util.SupervisedTransformerMixin):
     """Generalized linear mixed model.
 
     Supported targets: binomial and continuous. For polynomial target support, see PolynomialWrapper.
 
     This is a supervised encoder similar to TargetEncoder or MEstimateEncoder, but there are some advantages:
-    1) Solid statistical theory behind the technique. Mixed effects models are a mature branch of statistics.
-    2) No hyper-parameters to tune. The amount of shrinkage is automatically determined through the estimation process.
-    In short, the less observations a category has and/or the more the outcome varies for a category
-    then the higher the regularization towards "the prior" or "grand mean".
-    3) The technique is applicable for both continuous and binomial targets. If the target is continuous,
-    the encoder returns regularized difference of the observation's category from the global mean.
+
+        1. Solid statistical theory behind the technique. Mixed effects models are a mature branch of statistics.
+        2. No hyper-parameters to tune. The amount of shrinkage is automatically determined through the estimation
+        process. In short, the less observations a category has and/or the more the outcome varies for a category
+        then the higher the regularization towards "the prior" or "grand mean".
+        3. The technique is applicable for both continuous and binomial targets. If the target is continuous,
+        the encoder returns regularized difference of the observation's category from the global mean.
+
     If the target is binomial, the encoder returns regularized log odds per category.
 
     In comparison to JamesSteinEstimator, this encoder utilizes generalized linear mixed models from statsmodels library.
@@ -93,61 +94,23 @@ class GLMMEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     https://faculty.psau.edu.sa/filedownload/doc-12-pdf-a1997d0d31f84d13c1cdc44ac39a8f2c-original.pdf
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
-    def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True, handle_unknown='value', handle_missing='value', random_state=None, randomized=False, sigma=0.05, binomial_target=None):
-        self.verbose = verbose
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.cols = cols
+    def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True, handle_unknown='value',
+                 handle_missing='value', random_state=None, randomized=False, sigma=0.05, binomial_target=None):
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.ordinal_encoder = None
-        self._dim = None
         self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self.random_state = random_state
         self.randomized = randomized
         self.sigma = sigma
         self.binomial_target = binomial_target
-        self.feature_names = None
-
-    # noinspection PyUnusedLocal
-    def fit(self, X, y, **kwargs):
-        """Fit encoder according to X and binary y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Binary target values.
 
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # Unite parameters into pandas types
-        X, y = util.convert_inputs(X, y)
+    def _fit(self, X, y, **kwargs):
         y = y.astype(float)
 
-        self._dim = X.shape[1]
-
-        # If columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
             cols=self.cols,
@@ -160,59 +123,7 @@ def fit(self, X, y, **kwargs):
         # Training
         self.mapping = self._train(X_ordinal, y)
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # Store column names with approximately constant variance on the training data
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-        return self
-
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        When the data are used for model training, it is important to also pass the target in order to apply leave one out.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples] when transform by leave one out
-            None, when transform without target information (such as transform test set)
-
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # Unite the input into pandas types
-        X, y = util.convert_inputs(X, y, deep=True)
-
-        # Then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X, y=None):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -221,16 +132,12 @@ def transform(self, X, y=None, override_return_df=False):
 
         # Loop over the columns and replace the nominal values with the numbers
         X = self._score(X, y)
+        return X
 
-        # Postprocessing
-        # Note: We should not even convert these columns.
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+    def _more_tags(self):
+        tags = super()._more_tags()
+        tags["predict_depends_on_y"] = True
+        return tags
 
     def _train(self, X, y):
         # Initialize the output
@@ -308,22 +215,6 @@ def _score(self, X, y):
 
         return X
 
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-        if not isinstance(self.feature_names, list):
-            raise ValueError("Estimator has to be fitted to return feature names.")
-        else:
-            return self.feature_names
-
     def _rename_and_merge(self, X, y, col):
         """
         Statsmodels requires:
diff --git a/category_encoders/hashing.py b/category_encoders/hashing.py
index 9ca71105..f7990ce2 100644
--- a/category_encoders/hashing.py
+++ b/category_encoders/hashing.py
@@ -2,7 +2,6 @@
 
 import sys
 import hashlib
-from sklearn.base import BaseEstimator, TransformerMixin
 import category_encoders.utils as util
 import multiprocessing
 import pandas as pd
@@ -12,7 +11,7 @@
 __author__ = 'willmcginnis', 'LiuShulun'
 
 
-class HashingEncoder(BaseEstimator, TransformerMixin):
+class HashingEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
 
     """ A multivariate hashing implementation with configurable dimensionality/precision.
 
@@ -103,12 +102,17 @@ class HashingEncoder(BaseEstimator, TransformerMixin):
     https://booking.ai/dont-be-tricked-by-the-hashing-trick-192a6aae3087
 
     """
+    prefit_ordinal = False
+    encoding_relation = util.EncodingRelation.ONE_TO_M
 
-    def __init__(self, max_process=0, max_sample=0, verbose=0, n_components=8, cols=None, drop_invariant=False, return_df=True, hash_method='md5'):
+    def __init__(self, max_process=0, max_sample=0, verbose=0, n_components=8, cols=None, drop_invariant=False,
+                 return_df=True, hash_method='md5'):
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown="does not apply", handle_missing="does not apply")
 
         if max_process not in range(1, 128):
             if platform.system == 'Windows':
-                max_process = 1
+                self.max_process = 1
             else:
                 self.max_process = int(math.ceil(multiprocessing.cpu_count() / 2))
                 if self.max_process < 1:
@@ -122,62 +126,11 @@ def __init__(self, max_process=0, max_sample=0, verbose=0, n_components=8, cols=
         self.data_lines = 0
         self.X = None
 
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
         self.n_components = n_components
-        self.cols = cols
         self.hash_method = hash_method
-        self._dim = None
-        self.feature_names = None
 
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # first check the type
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # drop all output columns with 0 variance.
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
+    def _fit(self, X, y=None, **kwargs):
+        pass
 
     @staticmethod
     def require_data(self, data_lock, new_start, done_index, hashing_parts, cols, process_index):
@@ -200,8 +153,6 @@ def require_data(self, data_lock, new_start, done_index, hashing_parts, cols, pr
                 data_part = self.X.iloc[start_index: end_index]
                 # Always get df and check it after merge all data parts
                 data_part = self.hashing_trick(X_in=data_part, hashing_method=self.hash_method, N=self.n_components, cols=self.cols)
-                if self.drop_invariant:
-                    data_part = data_part.drop(columns=self.drop_cols)
                 part_index = int(math.ceil(end_index / self.max_sample))
                 hashing_parts.put({part_index: data_part})
                 if self.verbose == 5:
@@ -214,24 +165,14 @@ def require_data(self, data_lock, new_start, done_index, hashing_parts, cols, pr
         else:
             data_lock.release()
 
-    def transform(self, X, override_return_df=False):
+    def _transform(self, X):
         """
-        Call _transform() if you want to use single CPU with all samples
+        Call _transform_single_cpu() if you want to use single CPU with all samples
         """
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
+        self.X = X
 
-        # first check the type
-        self.X = util.convert_input(X)
         self.data_lines = len(self.X)
 
-        # then make sure that it is the right size
-        if self.X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {self.X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return self.X
-
         data_lock = multiprocessing.Manager().Lock()
         new_start = multiprocessing.Manager().Value('d', True)
         done_index = multiprocessing.Manager().Value('d', int(0))
@@ -268,13 +209,9 @@ def transform(self, X, override_return_df=False):
                 sort_data.append(list_data[part_index])
             if sort_data:
                 data = pd.concat(sort_data)
-        # Check if is_return_df
-        if self.return_df or override_return_df:
-            return data
-        else:
-            return data.values
+        return data
 
-    def _transform(self, X, override_return_df=False):
+    def _transform_single_cpu(self, X, override_return_df=False):
         """Perform the transformation to new categorical data.
 
         Parameters
@@ -306,7 +243,7 @@ def _transform(self, X, override_return_df=False):
         X = self.hashing_trick(X, hashing_method=self.hash_method, N=self.n_components, cols=self.cols)
 
         if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
+            X = X.drop(columns=self.invariant_cols)
 
         if self.return_df or override_return_df:
             return X
@@ -384,20 +321,3 @@ def hash_fn(x):
         X = pd.concat([X_cat, X_num], axis=1)
 
         return X
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-
-        if not isinstance(self.feature_names, list):
-            raise ValueError('Must fit data first. Affected feature names are not known before.')
-        else:
-            return self.feature_names
diff --git a/category_encoders/helmert.py b/category_encoders/helmert.py
index a4b68155..82a9d932 100644
--- a/category_encoders/helmert.py
+++ b/category_encoders/helmert.py
@@ -3,7 +3,6 @@
 
 import pandas as pd
 import numpy as np
-from sklearn.base import BaseEstimator, TransformerMixin
 from patsy.contrasts import Helmert
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
@@ -11,7 +10,7 @@
 __author__ = 'willmcginnis'
 
 
-class HelmertEncoder(BaseEstimator, TransformerMixin):
+class HelmertEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
     """Helmert contrast coding for encoding categorical features.
 
     Parameters
@@ -83,55 +82,17 @@ class HelmertEncoder(BaseEstimator, TransformerMixin):
     http://psych.colorado.edu/~carey/Courses/PSYC5741/handouts/Coding%20Categorical%20Variables%202006-03-03.pdf
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(self, verbose=0, cols=None, mapping=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value'):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = mapping
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
-        self.cols = cols
         self.ordinal_encoder = None
-        self._dim = None
-        self.feature_names = None
 
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # first check the type
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
+    def _fit(self, X, y=None, **kwargs):
 
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
@@ -153,54 +114,7 @@ def fit(self, X, y=None, **kwargs):
 
         self.mapping = mappings_out
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
-    def transform(self, X, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # first check the type
-        X = util.convert_input(X)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -208,14 +122,7 @@ def transform(self, X, override_return_df=False):
                 raise ValueError('Columns to be encoded can not contain new values')
 
         X = self.helmert_coding(X, mapping=self.mapping)
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     @staticmethod
     def fit_helmert_coding(col, values, handle_missing, handle_unknown):
@@ -271,20 +178,3 @@ def helmert_coding(X_in, mapping):
         cols = ['intercept'] + cols
 
         return X.reindex(columns=cols)
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-
-        if not isinstance(self.feature_names, list):
-            raise ValueError('Must fit data first. Affected feature names are not known before.')
-        else:
-            return self.feature_names
diff --git a/category_encoders/james_stein.py b/category_encoders/james_stein.py
index a24fbbde..ba64ec4e 100644
--- a/category_encoders/james_stein.py
+++ b/category_encoders/james_stein.py
@@ -3,7 +3,6 @@
 import pandas as pd
 import scipy
 from scipy import optimize
-from sklearn.base import BaseEstimator
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
 from sklearn.utils.random import check_random_state
@@ -11,7 +10,7 @@
 __author__ = 'Jan Motl'
 
 
-class JamesSteinEncoder(BaseEstimator, util.TransformerWithTargetMixin):
+class JamesSteinEncoder(util.BaseEncoder, util.SupervisedTransformerMixin):
     """James-Stein estimator.
 
     Supported targets: binomial and continuous. For polynomial target support, see PolynomialWrapper.
@@ -136,60 +135,21 @@ class JamesSteinEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     http://statweb.stanford.edu/~ckirby/brad/other/Article1977.pdf
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
-    def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True,
-                 handle_unknown='value', handle_missing='value', model='independent', random_state=None, randomized=False, sigma=0.05):
-        self.verbose = verbose
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.cols = cols
+    def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True, handle_unknown='value',
+                 handle_missing='value', model='independent', random_state=None, randomized=False, sigma=0.05):
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.ordinal_encoder = None
-        self._dim = None
         self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self.random_state = random_state
         self.randomized = randomized
         self.sigma = sigma
         self.model = model
-        self.feature_names = None
 
-    # noinspection PyUnusedLocal
-    def fit(self, X, y, **kwargs):
-        """Fit encoder according to X and binary y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Binary target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # Unite parameters into pandas types
-        X, y = util.convert_inputs(X, y)
-
-        self._dim = X.shape[1]
-
-        # If columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
+    def _fit(self, X, y, **kwargs):
 
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
@@ -223,59 +183,7 @@ def fit(self, X, y, **kwargs):
         else:
             raise ValueError("model='" + str(self.model) + "' is not a recognized option")
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # Store column names with approximately constant variance on the training data
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-        return self
-
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data. When the data are used for model training,
-        it is important to also pass the target in order to apply leave one out.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples] when transform by leave one out
-            None, when transform without target information (such as transform test set)
-
-
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # Unite the input into pandas types
-        X, y = util.convert_inputs(X, y, deep=True)
-
-        # Then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X, y=None):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -284,16 +192,12 @@ def transform(self, X, y=None, override_return_df=False):
 
         # Loop over columns and replace nominal values with WOE
         X = self._score(X, y)
+        return X
 
-        # Postprocessing
-        # Note: We should not even convert these columns.
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+    def _more_tags(self):
+        tags = super()._more_tags()
+        tags["predict_depends_on_y"] = True
+        return tags
 
     def _train_pooled(self, X, y):
         # Implemented based on reference [1]
@@ -549,6 +453,7 @@ def _train_beta(self, X, y):
 
         return mapping
 
+    # todo this score function is copied 4 times
     def _score(self, X, y):
         for col in self.cols:
             # Score the column
@@ -560,19 +465,3 @@ def _score(self, X, y):
                 X[col] = (X[col] * random_state_generator.normal(1., self.sigma, X[col].shape[0]))
 
         return X
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-        if not isinstance(self.feature_names, list):
-            raise ValueError("Estimator has to be fitted to return feature names.")
-        else:
-            return self.feature_names
diff --git a/category_encoders/leave_one_out.py b/category_encoders/leave_one_out.py
index 6fd565f3..dd9008b1 100644
--- a/category_encoders/leave_one_out.py
+++ b/category_encoders/leave_one_out.py
@@ -1,14 +1,13 @@
 """Leave one out coding"""
 import numpy as np
 import pandas as pd
-from sklearn.base import BaseEstimator
 import category_encoders.utils as util
 from sklearn.utils.random import check_random_state
 
 __author__ = 'hbghhy'
 
 
-class LeaveOneOutEncoder(BaseEstimator, util.TransformerWithTargetMixin):
+class LeaveOneOutEncoder(util.BaseEncoder, util.SupervisedTransformerMixin):
     """Leave one out coding for categorical features.
 
     This is very similar to target encoding but excludes the current row's
@@ -71,135 +70,44 @@ class LeaveOneOutEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     References
     ----------
 
-    .. [1] Strategies to encode categorical variables with many categories, from
-    https://www.kaggle.com/c/caterpillar-tube-pricing/discussion/15748#143154.
+    .. [1] Originally by Owen Zhang (reference broken), another short explanation at:
+    https://datascience.stackexchange.com/questions/10839/what-is-difference-between-one-hot-encoding-and-leave-one-out-encoding
 
     """
+    prefit_ordinal = False
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value', random_state=None, sigma=None):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
-        self.use_default_cols = cols is None  # if True, even a repeated call of fit() will select string columns from X
-        self.cols = cols
-        self._dim = None
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self._mean = None
         self.random_state = random_state
         self.sigma = sigma
-        self.feature_names = None
 
-    def fit(self, X, y, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # unite the input into pandas types
-        X, y = util.convert_inputs(X, y)
+    def _fit(self, X, y, **kwargs):
         y = y.astype(float)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.use_default_cols:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
         categories = self.fit_leave_one_out(
             X, y,
             cols=self.cols
         )
         self.mapping = categories
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples] when transform by leave one out
-            None, when transform without target information (such as transform test set)
-
-
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # unite the input into pandas types
-        X, y = util.convert_inputs(X, y, deep=True)
+    def _transform(self, X, y=None):
         if y is not None:
             y = y.astype(float)
 
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
         X = self.transform_leave_one_out(
             X, y,
             mapping=self.mapping
         )
+        return X
 
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+    def _more_tags(self):
+        tags = super()._more_tags()
+        tags["predict_depends_on_y"] = True
+        return tags
 
     def fit_leave_one_out(self, X_in, y, cols=None):
         X = X_in.copy(deep=True)
@@ -272,20 +180,3 @@ def transform_leave_one_out(self, X, y, mapping=None):
                 X[col] = X[col] * random_state_.normal(1., self.sigma, X[col].shape[0])
 
         return X
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-
-        if not isinstance(self.feature_names, list):
-            raise ValueError('Must fit data first. Affected feature names are not known before.')
-        else:
-            return self.feature_names
diff --git a/category_encoders/m_estimate.py b/category_encoders/m_estimate.py
index e55d3d4a..a42beaf5 100644
--- a/category_encoders/m_estimate.py
+++ b/category_encoders/m_estimate.py
@@ -1,7 +1,5 @@
 """M-probability estimate"""
 import numpy as np
-import pandas as pd
-from sklearn.base import BaseEstimator
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
 from sklearn.utils.random import check_random_state
@@ -9,7 +7,7 @@
 __author__ = 'Jan Motl'
 
 
-class MEstimateEncoder(BaseEstimator, util.TransformerWithTargetMixin):
+class MEstimateEncoder(util.BaseEncoder, util.SupervisedTransformerMixin):
     """M-probability estimate of likelihood.
 
     Supported targets: binomial and continuous. For polynomial target support, see PolynomialWrapper.
@@ -86,62 +84,23 @@ class MEstimateEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     https://en.wikipedia.org/wiki/Additive_smoothing#Generalized_to_the_case_of_known_incidence_rates
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value', random_state=None, randomized=False, sigma=0.05, m=1.0):
-        self.verbose = verbose
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.cols = cols
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.ordinal_encoder = None
-        self._dim = None
         self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self._sum = None
         self._count = None
         self.random_state = random_state
         self.randomized = randomized
         self.sigma = sigma
         self.m = m
-        self.feature_names = None
 
-    # noinspection PyUnusedLocal
-    def fit(self, X, y, **kwargs):
-        """Fit encoder according to X and binary or continuous y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Binary target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # Unite parameters into pandas types
-        X, y = util.convert_inputs(X, y)
-
-        self._dim = X.shape[1]
-
-        # If columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
+    def _fit(self, X, y, **kwargs):
 
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
@@ -155,59 +114,7 @@ def fit(self, X, y, **kwargs):
         # Training
         self.mapping = self._train(X_ordinal, y)
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # Store column names with approximately constant variance on the training data
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-        return self
-
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        When the data are used for model training, it is important to also pass the target in order to apply leave one out.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples] when transform by leave one out
-            None, when transform without target information (such as transform test set)
-
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # Unite the input into pandas types
-        X, y = util.convert_inputs(X, y, deep=True)
-
-        # Then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X, y=None):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -216,16 +123,12 @@ def transform(self, X, y=None, override_return_df=False):
 
         # Loop over the columns and replace the nominal values with the numbers
         X = self._score(X, y)
+        return X
 
-        # Postprocessing
-        # Note: We should not even convert these columns.
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+    def _more_tags(self):
+        tags = super()._more_tags()
+        tags["predict_depends_on_y"] = True
+        return tags
 
     def _train(self, X, y):
         # Initialize the output
@@ -275,19 +178,3 @@ def _score(self, X, y):
                 X[col] = (X[col] * random_state_generator.normal(1., self.sigma, X[col].shape[0]))
 
         return X
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-        if not isinstance(self.feature_names, list):
-            raise ValueError("Estimator has to be fitted to return feature names.")
-        else:
-            return self.feature_names
diff --git a/category_encoders/one_hot.py b/category_encoders/one_hot.py
index 3ced66dd..3d8fe31e 100644
--- a/category_encoders/one_hot.py
+++ b/category_encoders/one_hot.py
@@ -2,14 +2,13 @@
 import numpy as np
 import pandas as pd
 import warnings
-from sklearn.base import BaseEstimator, TransformerMixin
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
 
 __author__ = 'willmcginnis'
 
 
-class OneHotEncoder(BaseEstimator, TransformerMixin):
+class OneHotEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
     """Onehot (or dummy) coding for categorical features, produces one feature per category, each binary.
 
     Parameters
@@ -94,68 +93,28 @@ class OneHotEncoder(BaseEstimator, TransformerMixin):
     http://psych.colorado.edu/~carey/Courses/PSYC5741/handouts/Coding%20Categorical%20Variables%202006-03-03.pdf
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_N_UNIQUE
 
     def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True,
                  handle_missing='value', handle_unknown='value', use_cat_names=False):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = None
-        self.verbose = verbose
-        self.cols = cols
         self.ordinal_encoder = None
-        self._dim = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self.use_cat_names = use_cat_names
-        self.feature_names = None
 
     @property
     def category_mapping(self):
         return self.ordinal_encoder.category_mapping
 
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # first check the type
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
+    def _fit(self, X, y=None, **kwargs):
         oe_missing_strat = {
             'error': 'error',
             'return_nan': 'return_nan',
             'value': 'value',
             'indicator': 'return_nan',
         }[self.handle_missing]
-
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
             cols=self.cols,
@@ -165,21 +124,6 @@ def fit(self, X, y=None, **kwargs):
         self.ordinal_encoder = self.ordinal_encoder.fit(X)
         self.mapping = self.generate_mapping()
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = list(X_temp.columns)
-
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
     def generate_mapping(self):
         mapping = []
         found_column_counts = {}
@@ -244,40 +188,7 @@ def generate_mapping(self):
 
         return mapping
 
-    def transform(self, X, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self._dim is None:
-            raise ValueError(
-                'Must train encoder before it can be used to transform data.')
-
-        # first check the type
-        X = util.convert_input(X)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X if self.return_df else X.values
-
+    def _transform(self, X):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -285,14 +196,7 @@ def transform(self, X, override_return_df=False):
                 raise ValueError('Columns to be encoded can not contain new values')
 
         X = self.get_dummies(X)
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     def inverse_transform(self, X_in):
         """
@@ -407,21 +311,3 @@ def reverse_dummies(self, X, mapping):
             out_cols = X.columns.values.tolist()
 
         return X
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-
-        if not isinstance(self.feature_names, list):
-            raise ValueError(
-                'Must transform data first. Affected feature names are not known before.')
-        else:
-            return self.feature_names
diff --git a/category_encoders/ordinal.py b/category_encoders/ordinal.py
index 1b330b35..ff43eaec 100644
--- a/category_encoders/ordinal.py
+++ b/category_encoders/ordinal.py
@@ -2,14 +2,13 @@
 
 import numpy as np
 import pandas as pd
-from sklearn.base import BaseEstimator, TransformerMixin
 import category_encoders.utils as util
 import warnings
 
 __author__ = 'willmcginnis'
 
 
-class OrdinalEncoder(BaseEstimator, TransformerMixin):
+class OrdinalEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
     """Encodes categorical features as ordinal, in one ordered feature.
 
     Ordinal encoding uses a single column of integers to represent the classes. An optional mapping dict can be passed
@@ -83,59 +82,24 @@ class OrdinalEncoder(BaseEstimator, TransformerMixin):
     http://psych.colorado.edu/~carey/Courses/PSYC5741/handouts/Coding%20Categorical%20Variables%202006-03-03.pdf
 
     """
+    prefit_ordinal = False
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(self, verbose=0, mapping=None, cols=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value'):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
-        self.cols = cols
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
+        self.mapping_supplied = mapping is not None
         self.mapping = mapping
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
-        self._dim = None
-        self.feature_names = None
 
     @property
     def category_mapping(self):
         return self.mapping
 
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # first check the type
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
+    def _fit(self, X, y=None, **kwargs):
+        # reset mapping in case of refit
+        if not self.mapping_supplied:
+            self.mapping = None
         _, categories = self.ordinal_encoding(
             X,
             mapping=self.mapping,
@@ -145,59 +109,7 @@ def fit(self, X, y=None, **kwargs):
         )
         self.mapping = categories
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # drop all output columns with 0 variance.
-        if self.drop_invariant:
-            self.drop_cols = []
-
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
-    def transform(self, X, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Will use the mapping (if available) and the column list (if available, otherwise every column) to encode the
-        data ordinarily.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError(
-                'Must train encoder before it can be used to transform data.')
-
-        # first check the type
-        X = util.convert_input(X)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X if self.return_df else X.values
+    def _transform(self, X):
 
         X, _ = self.ordinal_encoding(
             X,
@@ -206,14 +118,7 @@ def transform(self, X, override_return_df=False):
             handle_unknown=self.handle_unknown,
             handle_missing=self.handle_missing
         )
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     def inverse_transform(self, X_in):
         """
@@ -337,19 +242,3 @@ def ordinal_encoding(X_in, mapping=None, cols=None, handle_unknown='value', hand
                 mapping_out.append({'col': col, 'mapping': data, 'data_type': X[col].dtype}, )
 
         return X, mapping_out
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-        if not isinstance(self.feature_names, list):
-            raise ValueError("Estimator has to be fitted to return feature names.")
-        else:
-            return self.feature_names
diff --git a/category_encoders/polynomial.py b/category_encoders/polynomial.py
index c8313645..33b33244 100644
--- a/category_encoders/polynomial.py
+++ b/category_encoders/polynomial.py
@@ -4,13 +4,12 @@
 import pandas as pd
 from patsy.contrasts import Poly
 from category_encoders.ordinal import OrdinalEncoder
-from sklearn.base import BaseEstimator, TransformerMixin
 import category_encoders.utils as util
 
 __author__ = 'willmcginnis'
 
 
-class PolynomialEncoder(BaseEstimator, TransformerMixin):
+class PolynomialEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
     """Polynomial contrast coding for the encoding of categorical features.
 
     Parameters
@@ -82,56 +81,17 @@ class PolynomialEncoder(BaseEstimator, TransformerMixin):
     http://psych.colorado.edu/~carey/Courses/PSYC5741/handouts/Coding%20Categorical%20Variables%202006-03-03.pdf
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_N_UNIQUE
 
     def __init__(self, verbose=0, cols=None, mapping=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value'):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = mapping
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
-        self.cols = cols
         self.ordinal_encoder = None
-        self._dim = None
-        self.feature_names = None
 
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # if the input dataset isn't already a dataframe, convert it to one (using default column names)
-        # first check the type
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
+    def _fit(self, X, y=None, **kwargs):
 
         # train an ordinal pre-encoder
         self.ordinal_encoder = OrdinalEncoder(
@@ -153,54 +113,7 @@ def fit(self, X, y=None, **kwargs):
 
         self.mapping = mappings_out
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # drop all output columns with 0 variance.
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
-    def transform(self, X, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # first check the type
-        X = util.convert_input(X)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
+    def _transform(self, X):
 
         X = self.ordinal_encoder.transform(X)
 
@@ -209,14 +122,7 @@ def transform(self, X, override_return_df=False):
                 raise ValueError('Columns to be encoded can not contain new values')
 
         X = self.polynomial_coding(X, self.mapping)
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     @staticmethod
     def fit_polynomial_coding(col, values, handle_missing, handle_unknown):
@@ -272,19 +178,3 @@ def polynomial_coding(X_in, mapping):
         cols = ['intercept'] + cols
 
         return X.reindex(columns=cols)
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-        if not isinstance(self.feature_names, list):
-            raise ValueError("Estimator has to be fitted to return feature names.")
-        else:
-            return self.feature_names
diff --git a/category_encoders/quantile_encoder.py b/category_encoders/quantile_encoder.py
index 8a053c56..a41f6613 100644
--- a/category_encoders/quantile_encoder.py
+++ b/category_encoders/quantile_encoder.py
@@ -10,7 +10,7 @@
 import operator
 
 
-class QuantileEncoder(BaseEstimator, util.TransformerWithTargetMixin):
+class QuantileEncoder(util.BaseEncoder, util.SupervisedTransformerMixin):
     """Quantile Encoding for categorical features.
 
     This a statistically modified version of target MEstimate encoder where selected features
@@ -78,6 +78,8 @@ class QuantileEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     .. [4] Additive smoothing, from https://en.wikipedia.org/wiki/Additive_smoothing#Generalized_to_the_case_of_known_incidence_rates
     .. [5] Target encoding done the right way https://maxhalford.github.io/blog/target-encoding/
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(
         self,
@@ -90,53 +92,16 @@ def __init__(
         quantile=0.5,
         m=1.0,
     ):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
-        self.cols = cols
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.ordinal_encoder = None
-        self._dim = None
         self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
-        self.feature_names = None
         self.quantile = quantile
         self.m = m
 
-    def fit(self, X, y, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-        self : encoder
-            Returns self.
-        """
-
-        # unite the input into pandas types
-        X, y = util.convert_inputs(X, y)
+    def _fit(self, X, y, **kwargs):
         y = y.astype(float)
 
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == "error":
-            if X[self.cols].isnull().any().any():
-                raise ValueError("Columns to be encoded can not contain null")
-
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
             cols=self.cols,
@@ -147,22 +112,6 @@ def fit(self, X, y, **kwargs):
         X_ordinal = self.ordinal_encoder.transform(X)
         self.mapping = self.fit_quantile_encoding(X_ordinal, y)
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = list(X_temp.columns)
-
-        if self.drop_invariant:
-            self.drop_cols = []
-            X_temp = self.transform(X)
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
     def fit_quantile_encoding(self, X, y):
         mapping = {}
 
@@ -194,38 +143,7 @@ def fit_quantile_encoding(self, X, y):
 
         return mapping
 
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples]
-            None, when transform without target info (such as transform test set)
-
-        Returns
-        -------
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-        """
-
-        if self.handle_missing == "error":
-            if X[self.cols].isnull().any().any():
-                raise ValueError("Columns to be encoded can not contain null")
-
-        if self._dim is None:
-            raise ValueError("Must train encoder before it can be used to transform data.")
-
-        # unite the input into pandas types
-        X, y = util.convert_inputs(X, y)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f"Unexpected input dimension {X.shape[1]}, expected {self._dim}")
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X, y=None):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == "error":
@@ -233,14 +151,7 @@ def transform(self, X, y=None, override_return_df=False):
                 raise ValueError("Unexpected categories found in dataframe")
 
         X = self.quantile_encode(X)
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     def quantile_encode(self, X_in):
         X = X_in.copy(deep=True)
@@ -250,23 +161,8 @@ def quantile_encode(self, X_in):
 
         return X
 
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-        """
-
-        if not isinstance(self.feature_names, list):
-            raise ValueError("Must fit data first. Affected feature names are not known before.")
-        else:
-            return self.feature_names
-
 
+# todo does not fit in schema since it is an ensemble of other encoders
 class SummaryEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     """Summary Encoding for categorical features.
 
@@ -337,6 +233,7 @@ class SummaryEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     .. [4] Additive smoothing, from https://en.wikipedia.org/wiki/Additive_smoothing#Generalized_to_the_case_of_known_incidence_rates
     .. [5] Target encoding done the right way https://maxhalford.github.io/blog/target-encoding/
     """
+    encoding_relation = util.EncodingRelation.ONE_TO_M
 
     def __init__(
         self,
@@ -354,6 +251,7 @@ def __init__(
         self.drop_cols = []
         self.verbose = verbose
         self.cols = cols
+        self.use_default_cols = cols is None  # if True, even a repeated call of fit() will select string columns from X
         self.ordinal_encoder = None
         self._dim = None
         self.mapping = None
@@ -365,9 +263,27 @@ def __init__(
         self.encoder_list = None
 
     def fit(self, X, y):
+        """Fits the encoder according to X and y by fitting the individual encoders.
+
+        Parameters
+        ----------
+
+        X : array-like, shape = [n_samples, n_features]
+            Training vectors, where n_samples is the number of samples
+            and n_features is the number of features.
+        y : array-like, shape = [n_samples]
+            Target values.
+
+        Returns
+        -------
+
+        self : encoder
+            Returns self.
+
+        """
         X, y = util.convert_inputs(X, y)
 
-        if self.cols is None:
+        if self.use_default_cols:
             self.cols = util.get_obj_cols(X)
         else:
             self.cols = util.convert_cols_to_list(self.cols)
@@ -390,11 +306,11 @@ def fit(self, X, y):
             )
             enc.fit(X.copy(), y)
             encoder_list.append(enc)
-            self.drop_cols += enc.drop_cols
+            self.drop_cols += enc.invariant_cols
         self.feature_names = reduce(
             operator.add,
             [
-                [self._get_col_name(c, enc.quantile) for enc in encoder_list if c not in enc.drop_cols]
+                [self._get_col_name(c, enc.quantile) for enc in encoder_list if c not in enc.invariant_cols]
                 if c in self.cols
                 else [c]
                 for c in X.columns
diff --git a/category_encoders/sum_coding.py b/category_encoders/sum_coding.py
index e379548e..fa55ae4e 100644
--- a/category_encoders/sum_coding.py
+++ b/category_encoders/sum_coding.py
@@ -3,14 +3,13 @@
 import pandas as pd
 import numpy as np
 from patsy.contrasts import Sum
-from sklearn.base import BaseEstimator, TransformerMixin
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
 
 __author__ = 'willmcginnis'
 
 
-class SumEncoder(BaseEstimator, TransformerMixin):
+class SumEncoder(util.BaseEncoder, util.UnsupervisedTransformerMixin):
     """Sum contrast coding for the encoding of categorical features.
 
     Parameters
@@ -82,57 +81,17 @@ class SumEncoder(BaseEstimator, TransformerMixin):
     http://psych.colorado.edu/~carey/Courses/PSYC5741/handouts/Coding%20Categorical%20Variables%202006-03-03.pdf
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_N_UNIQUE
 
     def __init__(self, verbose=0, cols=None, mapping=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value'):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.mapping = mapping
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
-        self.cols = cols
         self.ordinal_encoder = None
-        self._dim = None
-        self.feature_names = None
-
-    def fit(self, X, y=None, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # if the input dataset isn't already a dataframe, convert it to one (using default column names)
-        # first check the type
-        X = util.convert_input(X)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
 
+    def _fit(self, X, y=None, **kwargs):
         # train an ordinal pre-encoder
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
@@ -153,55 +112,7 @@ def fit(self, X, y=None, **kwargs):
 
         self.mapping = mappings_out
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # drop all output columns with 0 variance.
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
-
-    def transform(self, X, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # first check the type
-        X = util.convert_input(X)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -209,14 +120,7 @@ def transform(self, X, override_return_df=False):
                 raise ValueError('Columns to be encoded can not contain new values')
 
         X = self.sum_coding(X, mapping=self.mapping)
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     @staticmethod
     def fit_sum_coding(col, values, handle_missing, handle_unknown):
@@ -272,19 +176,3 @@ def sum_coding(X_in, mapping):
         cols = ['intercept'] + cols
 
         return X.reindex(columns=cols)
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-        if not isinstance(self.feature_names, list):
-            raise ValueError("Estimator has to be fitted to return feature names.")
-        else:
-            return self.feature_names
diff --git a/category_encoders/target_encoder.py b/category_encoders/target_encoder.py
index e09910b2..5988c710 100644
--- a/category_encoders/target_encoder.py
+++ b/category_encoders/target_encoder.py
@@ -1,15 +1,13 @@
 """Target Encoder"""
-import warnings
 import numpy as np
-import pandas as pd
-from sklearn.base import BaseEstimator
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
+import warnings
 
 __author__ = 'chappers'
 
 
-class TargetEncoder(BaseEstimator, util.TransformerWithTargetMixin):
+class TargetEncoder(util.BaseEncoder, util.SupervisedTransformerMixin):
     """Target encoding for categorical features.
 
     Supported targets: binomial and continuous. For polynomial target support, see PolynomialWrapper.
@@ -81,14 +79,13 @@ class TargetEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     https://dl.acm.org/citation.cfm?id=507538
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True, handle_missing='value',
-                     handle_unknown='value', min_samples_leaf=1, smoothing=1.0):
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.verbose = verbose
-        self.cols = cols
+                 handle_unknown='value', min_samples_leaf=1, smoothing=1.0):
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.ordinal_encoder = None
         self.min_samples_leaf = min_samples_leaf
         if min_samples_leaf == 1:
@@ -96,50 +93,14 @@ def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True, h
                           "See https://github.com/scikit-learn-contrib/category_encoders/issues/327",
                           category=FutureWarning)
         self.smoothing = smoothing
-        if min_samples_leaf == 1.0:
+        if smoothing == 1.0:
             warnings.warn("Default parameter smoothing will change in version 2.6."
                           "See https://github.com/scikit-learn-contrib/category_encoders/issues/327",
                           category=FutureWarning)
-        self._dim = None
         self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self._mean = None
-        self.feature_names = None
-
-    def fit(self, X, y, **kwargs):
-        """Fit encoder according to X and y.
-
-        Parameters
-        ----------
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Target values.
-
-        Returns
-        -------
-        self : encoder
-            Returns self.
-
-        """
-
-        # unite the input into pandas types
-        X, y = util.convert_inputs(X, y)
-
-        self._dim = X.shape[1]
-
-        # if columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
 
+    def _fit(self, X, y, **kwargs):
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
             cols=self.cols,
@@ -149,22 +110,6 @@ def fit(self, X, y, **kwargs):
         self.ordinal_encoder = self.ordinal_encoder.fit(X)
         X_ordinal = self.ordinal_encoder.transform(X)
         self.mapping = self.fit_target_encoding(X_ordinal, y)
-        
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = list(X_temp.columns)
-
-        if self.drop_invariant:
-            self.drop_cols = []
-            X_temp = self.transform(X)
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-
-        return self
 
     def fit_target_encoding(self, X, y):
         mapping = {}
@@ -179,7 +124,6 @@ def fit_target_encoding(self, X, y):
 
             smoove = 1 / (1 + np.exp(-(stats['count'] - self.min_samples_leaf) / self.smoothing))
             smoothing = prior * (1 - smoove) + stats['mean'] * smoove
-            # @ToDo delete this in version 2.6
             smoothing[stats['count'] == 1] = prior
 
             if self.handle_unknown == 'return_nan':
@@ -196,39 +140,7 @@ def fit_target_encoding(self, X, y):
 
         return mapping
 
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data.
-
-        Parameters
-        ----------
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples] when transform by leave one out
-            None, when transform without target info (such as transform test set)
-            
-        Returns
-        -------
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # unite the input into pandas types
-        X, y = util.convert_inputs(X, y)
-
-        # then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X, y=None):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -236,14 +148,7 @@ def transform(self, X, y=None, override_return_df=False):
                 raise ValueError('Unexpected categories found in dataframe')
 
         X = self.target_encode(X)
-
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     def target_encode(self, X_in):
         X = X_in.copy(deep=True)
@@ -252,20 +157,3 @@ def target_encode(self, X_in):
             X[col] = X[col].map(self.mapping[col])
 
         return X
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-
-        if not isinstance(self.feature_names, list):
-            raise ValueError('Must fit data first. Affected feature names are not known before.')
-        else:
-            return self.feature_names
diff --git a/category_encoders/utils.py b/category_encoders/utils.py
index b222f2ab..60930b3c 100644
--- a/category_encoders/utils.py
+++ b/category_encoders/utils.py
@@ -1,7 +1,13 @@
 """A collection of shared utilities for all encoders, not intended for external use."""
+from abc import abstractmethod
+from enum import Enum, auto
 
 import pandas as pd
 import numpy as np
+import sklearn.base
+from sklearn.base import BaseEstimator, TransformerMixin
+from sklearn.exceptions import NotFittedError
+from typing import Dict, List, Optional, Union
 from scipy.sparse import csr_matrix
 
 __author__ = 'willmcginnis'
@@ -33,6 +39,9 @@ def get_obj_cols(df):
         if dt == 'object' or is_category(dt):
             obj_cols.append(df.columns.values[idx])
 
+    if not obj_cols:
+        print("Warning: No categorical columns found. Calling 'transform' will only return input data.")
+
     return obj_cols
 
 
@@ -175,7 +184,281 @@ def get_generated_cols(X_original, X_transformed, to_transform):
     return current_cols
 
 
+class EncodingRelation(Enum):
+    # one input feature get encoded into one output feature
+    ONE_TO_ONE = auto()
+    # one input feature get encoded into as many output features as it has distinct values
+    ONE_TO_N_UNIQUE = auto()
+    # one input feature get encoded into m output features that are not the number of distinct values
+    ONE_TO_M = auto()
+    # all N input features are encoded into M output features.
+    # The encoding is done globally on all the input not on a per-feature basis
+    N_TO_M = auto()
+
+
+def get_docstring_output_shape(in_out_relation: EncodingRelation):
+    if in_out_relation == EncodingRelation.ONE_TO_ONE:
+        return "n_features"
+    elif in_out_relation == EncodingRelation.ONE_TO_N_UNIQUE:
+        return "n_features * respective cardinality"
+    elif in_out_relation == EncodingRelation.ONE_TO_M:
+        return "M features (n_features < M)"
+    elif in_out_relation == EncodingRelation.N_TO_M:
+        return "M features (M can be anything)"
+
+
+class BaseEncoder(BaseEstimator):
+    _dim: Optional[int]
+    cols: List[str]
+    use_default_cols: bool
+    handle_missing: str
+    handle_unknown: str
+    verbose: int
+    drop_invariant: bool
+    invariant_cols: List[str] = []
+    feature_names: Union[None,  List[str]] = None
+    return_df: bool
+    supervised: bool
+    encoding_relation: EncodingRelation
+
+    INVARIANCE_THRESHOLD = 10e-5  # columns with variance less than this will be considered constant / invariant
+
+    def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True,
+                 handle_unknown='value', handle_missing='value', **kwargs):
+        """
+
+        Parameters
+        ----------
+
+        verbose: int
+            integer indicating verbosity of output. 0 for none.
+        cols: list
+            a list of columns to encode, if None, all string and categorical columns
+            will be encoded.
+        drop_invariant: bool
+            boolean for whether or not to drop columns with 0 variance.
+        return_df: bool
+            boolean for whether to return a pandas DataFrame from transform and inverse transform
+            (otherwise it will be a numpy array).
+        handle_missing: str
+            how to handle missing values at fit time. Options are 'error', 'return_nan',
+            and 'value'. Default 'value', which treat NaNs as a countable category at
+            fit time.
+        handle_unknown: str, int or dict of {column : option, ...}.
+            how to handle unknown labels at transform time. Options are 'error'
+            'return_nan', 'value' and int. Defaults to None which uses NaN behaviour
+            specified at fit time. Passing an int will fill with this int value.
+        kwargs: dict.
+            additional encoder specific parameters like regularisation.
+        """
+        self.return_df = return_df
+        self.drop_invariant = drop_invariant
+        self.invariant_cols = []
+        self.verbose = verbose
+        self.use_default_cols = cols is None  # if True, even a repeated call of fit() will select string columns from X
+        self.cols = cols
+        self.mapping = None
+        self.handle_unknown = handle_unknown
+        self.handle_missing = handle_missing
+        self.feature_names = None
+        self._dim = None
+
+    def fit(self, X, y=None, **kwargs):
+        """Fits the encoder according to X and y.
+        
+        Parameters
+        ----------
+
+        X : array-like, shape = [n_samples, n_features]
+            Training vectors, where n_samples is the number of samples
+            and n_features is the number of features.
+        y : array-like, shape = [n_samples]
+            Target values.
+
+        Returns
+        -------
+
+        self : encoder
+            Returns self.
+
+        """
+        self._check_fit_inputs(X, y)
+        X, y = convert_inputs(X, y)
+
+        self._dim = X.shape[1]
+        self._get_fit_columns(X)
+
+        if self.handle_missing == 'error':
+            if X[self.cols].isnull().any().any():
+                raise ValueError('Columns to be encoded can not contain null')
+
+        self._fit(X, y, **kwargs)
+
+        # for finding invariant columns transform without y (as is done on the test set)
+        X_transformed = self.transform(X, override_return_df=True)
+        self.feature_names = X_transformed.columns.tolist()
+
+        # drop all output columns with 0 variance.
+        if self.drop_invariant:
+            generated_cols = get_generated_cols(X, X_transformed, self.cols)
+            self.invariant_cols = [x for x in generated_cols if X_transformed[x].var() <= self.INVARIANCE_THRESHOLD]
+            self.feature_names = [x for x in self.feature_names if x not in self.invariant_cols]
+
+        return self
+
+    def _check_fit_inputs(self, X, y):
+        if self._get_tags().get('supervised_encoder') and y is None:
+            raise ValueError('Supervised encoders need a target for the fitting. The target cannot be None')
+
+    def _check_transform_inputs(self, X):
+        if self.handle_missing == 'error':
+            if X[self.cols].isnull().any().any():
+                raise ValueError('Columns to be encoded can not contain null')
+
+        if self._dim is None:
+            raise NotFittedError('Must train encoder before it can be used to transform data.')
+
+        # then make sure that it is the right size
+        if X.shape[1] != self._dim:
+            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
+
+    def _drop_invariants(self, X: pd.DataFrame, override_return_df: bool) -> Union[np.ndarray, pd.DataFrame]:
+        if self.drop_invariant:
+            X = X.drop(columns=self.invariant_cols)
+
+        if self.return_df or override_return_df:
+            return X
+        else:
+            return X.values
+
+    def _get_fit_columns(self, X: pd.DataFrame) -> None:
+        """ Determine columns used by encoder.
+
+        Note that the implementation also deals with re-fitting the same encoder object with different columns.
+
+        :param X: input data frame
+        :return: none, sets self.cols as a side effect
+        """
+        # if columns aren't passed, just use every string column
+        if self.use_default_cols:
+            self.cols = get_obj_cols(X)
+        else:
+            self.cols = convert_cols_to_list(self.cols)
+
+    def get_feature_names(self) -> List[str]:
+        """
+        Returns the names of all transformed / added columns.
+
+        Returns
+        -------
+        feature_names: list
+            A list with all feature names transformed or added.
+            Note: potentially dropped features (because the feature is constant/invariant) are not included!
+
+        """
+        if not isinstance(self.feature_names, list):
+            raise NotFittedError("Estimator has to be fitted to return feature names.")
+        else:
+            return self.feature_names
+
+    @abstractmethod
+    def _fit(self, X: pd.DataFrame, y: Optional[pd.Series], **kwargs):
+        ...
+
+
+class SupervisedTransformerMixin(sklearn.base.TransformerMixin):
+
+    def _more_tags(self):
+        return {'supervised_encoder': True}
+
+    def transform(self, X, y=None, override_return_df=False):
+        """Perform the transformation to new categorical data.
+
+        Some encoders behave differently on whether y is given or not. This is mainly due to regularisation
+        in order to avoid overfitting.
+        On training data transform should be called with y, on test data without.
+
+        Parameters
+        ----------
+
+        X : array-like, shape = [n_samples, n_features]
+        y : array-like, shape = [n_samples] or None
+        override_return_df : bool
+            override self.return_df to force to return a data frame
+
+        Returns
+        -------
+
+        p : array or DataFrame, shape = [n_samples, n_features_out]
+            Transformed values with encoding applied.
+
+        """
+        # first check the type
+        X, y = convert_inputs(X, y, deep=True)
+        self._check_transform_inputs(X)
+
+        if not list(self.cols):
+            return X
+
+        X = self._transform(X, y)
+
+        return self._drop_invariants(X, override_return_df)
+
+    @abstractmethod
+    def _transform(self, X: pd.DataFrame, y: pd.Series) -> pd.DataFrame:
+        ...
+
+    def fit_transform(self, X, y=None, **fit_params):
+        """
+        Encoders that utilize the target must make sure that the training data are transformed with:
+             transform(X, y)
+        and not with:
+            transform(X)
+        """
+        if y is None:
+            raise TypeError('fit_transform() missing argument: ''y''')
+        return self.fit(X, y, **fit_params).transform(X, y)
+
+
+class UnsupervisedTransformerMixin(sklearn.base.TransformerMixin):
+
+    def transform(self, X, override_return_df=False):
+        """Perform the transformation to new categorical data.
+
+        Parameters
+        ----------
+
+        X : array-like, shape = [n_samples, n_features]
+        override_return_df : bool
+            override self.return_df to force to return a data frame
+
+        Returns
+        -------
+
+        p : array or DataFrame, shape = [n_samples, n_features_out]
+            Transformed values with encoding applied.
+
+        """
+        # first check the type
+        X = convert_input(X, deep=True)
+        self._check_transform_inputs(X)
+
+        if not list(self.cols):
+            return X
+
+        X = self._transform(X)
+        return self._drop_invariants(X, override_return_df)
+
+    @abstractmethod
+    def _transform(self, X) -> pd.DataFrame:
+        ...
+
+
 class TransformerWithTargetMixin:
+
+    def _more_tags(self):
+        return {'supervised_encoder': True}
+
     def fit_transform(self, X, y=None, **fit_params):
         """
         Encoders that utilize the target must make sure that the training data are transformed with:
diff --git a/category_encoders/woe.py b/category_encoders/woe.py
index 493b73e0..3ad2c39d 100644
--- a/category_encoders/woe.py
+++ b/category_encoders/woe.py
@@ -1,7 +1,5 @@
 """Weight of Evidence"""
 import numpy as np
-import pandas as pd
-from sklearn.base import BaseEstimator
 from category_encoders.ordinal import OrdinalEncoder
 import category_encoders.utils as util
 from sklearn.utils.random import check_random_state
@@ -9,7 +7,7 @@
 __author__ = 'Jan Motl'
 
 
-class WOEEncoder(BaseEstimator, util.TransformerWithTargetMixin):
+class WOEEncoder(util.BaseEncoder, util.SupervisedTransformerMixin):
     """Weight of Evidence coding for categorical features.
 
     Supported targets: binomial. For polynomial target support, see PolynomialWrapper.
@@ -75,51 +73,22 @@ class WOEEncoder(BaseEstimator, util.TransformerWithTargetMixin):
     https://www.listendata.com/2015/03/weight-of-evidence-woe-and-information.html
 
     """
+    prefit_ordinal = True
+    encoding_relation = util.EncodingRelation.ONE_TO_ONE
 
     def __init__(self, verbose=0, cols=None, drop_invariant=False, return_df=True,
                  handle_unknown='value', handle_missing='value', random_state=None, randomized=False, sigma=0.05, regularization=1.0):
-        self.verbose = verbose
-        self.return_df = return_df
-        self.drop_invariant = drop_invariant
-        self.drop_cols = []
-        self.cols = cols
+        super().__init__(verbose=verbose, cols=cols, drop_invariant=drop_invariant, return_df=return_df,
+                         handle_unknown=handle_unknown, handle_missing=handle_missing)
         self.ordinal_encoder = None
-        self._dim = None
-        self.mapping = None
-        self.handle_unknown = handle_unknown
-        self.handle_missing = handle_missing
         self._sum = None
         self._count = None
         self.random_state = random_state
         self.randomized = randomized
         self.sigma = sigma
         self.regularization = regularization
-        self.feature_names = None
-
-    # noinspection PyUnusedLocal
-    def fit(self, X, y, **kwargs):
-        """Fit encoder according to X and binary y.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-            Training vectors, where n_samples is the number of samples
-            and n_features is the number of features.
-        y : array-like, shape = [n_samples]
-            Binary target values.
-
-        Returns
-        -------
-
-        self : encoder
-            Returns self.
-
-        """
-
-        # Unite parameters into pandas types
-        X, y = util.convert_inputs(X, y)
 
+    def _fit(self, X, y, **kwargs):
         # The label must be binary with values {0,1}
         unique = y.unique()
         if len(unique) != 2:
@@ -131,18 +100,6 @@ def fit(self, X, y, **kwargs):
         if np.min(unique) > 0:
             raise ValueError("The target column y must be binary with values {0, 1}. Value 0 was not found in the target.")
 
-        self._dim = X.shape[1]
-
-        # If columns aren't passed, just use every string column
-        if self.cols is None:
-            self.cols = util.get_obj_cols(X)
-        else:
-            self.cols = util.convert_cols_to_list(self.cols)
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
         self.ordinal_encoder = OrdinalEncoder(
             verbose=self.verbose,
             cols=self.cols,
@@ -155,57 +112,7 @@ def fit(self, X, y, **kwargs):
         # Training
         self.mapping = self._train(X_ordinal, y)
 
-        X_temp = self.transform(X, override_return_df=True)
-        self.feature_names = X_temp.columns.tolist()
-
-        # Store column names with approximately constant variance on the training data
-        if self.drop_invariant:
-            self.drop_cols = []
-            generated_cols = util.get_generated_cols(X, X_temp, self.cols)
-            self.drop_cols = [x for x in generated_cols if X_temp[x].var() <= 10e-5]
-            try:
-                [self.feature_names.remove(x) for x in self.drop_cols]
-            except KeyError as e:
-                if self.verbose > 0:
-                    print(f"Could not remove column from feature names. Not found in generated cols.\n{e}")
-        return self
-
-    def transform(self, X, y=None, override_return_df=False):
-        """Perform the transformation to new categorical data. When the data are used for model training,
-        it is important to also pass the target in order to apply leave one out.
-
-        Parameters
-        ----------
-
-        X : array-like, shape = [n_samples, n_features]
-        y : array-like, shape = [n_samples] when transform by leave one out
-            None, when transform without target information (such as transform test set)
-
-        Returns
-        -------
-
-        p : array, shape = [n_samples, n_numeric + N]
-            Transformed values with encoding applied.
-
-        """
-
-        if self.handle_missing == 'error':
-            if X[self.cols].isnull().any().any():
-                raise ValueError('Columns to be encoded can not contain null')
-
-        if self._dim is None:
-            raise ValueError('Must train encoder before it can be used to transform data.')
-
-        # Unite the input into pandas types
-        X, y = util.convert_inputs(X, y, deep=True)
-
-        # Then make sure that it is the right size
-        if X.shape[1] != self._dim:
-            raise ValueError(f'Unexpected input dimension {X.shape[1]}, expected {self._dim}')
-
-        if not list(self.cols):
-            return X
-
+    def _transform(self, X, y=None):
         X = self.ordinal_encoder.transform(X)
 
         if self.handle_unknown == 'error':
@@ -214,16 +121,7 @@ def transform(self, X, y=None, override_return_df=False):
 
         # Loop over columns and replace nominal values with WOE
         X = self._score(X, y)
-
-        # Postprocessing
-        # Note: We should not even convert these columns.
-        if self.drop_invariant:
-            X = X.drop(columns=self.drop_cols)
-
-        if self.return_df or override_return_df:
-            return X
-        else:
-            return X.values
+        return X
 
     def _train(self, X, y):
         # Initialize the output
@@ -275,19 +173,3 @@ def _score(self, X, y):
                 X[col] = (X[col] * random_state_generator.normal(1., self.sigma, X[col].shape[0]))
 
         return X
-
-    def get_feature_names(self):
-        """
-        Returns the names of all transformed / added columns.
-
-        Returns
-        -------
-        feature_names: list
-            A list with all feature names transformed or added.
-            Note: potentially dropped features are not included!
-
-        """
-        if not isinstance(self.feature_names, list):
-            raise ValueError("Estimator has to be fitted to return feature names.")
-        else:
-            return self.feature_names
diff --git a/docs/source/backward_difference.rst b/docs/source/backward_difference.rst
index d863aab6..a3f4b5c5 100755
--- a/docs/source/backward_difference.rst
+++ b/docs/source/backward_difference.rst
@@ -3,3 +3,4 @@ Backward Difference Coding
 
 .. autoclass:: category_encoders.backward_difference.BackwardDifferenceEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/basen.rst b/docs/source/basen.rst
index 177e517d..fdb933e9 100755
--- a/docs/source/basen.rst
+++ b/docs/source/basen.rst
@@ -3,3 +3,4 @@ BaseN
 
 .. autoclass:: category_encoders.basen.BaseNEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/binary.rst b/docs/source/binary.rst
index e1d22cda..d9b780b3 100755
--- a/docs/source/binary.rst
+++ b/docs/source/binary.rst
@@ -3,3 +3,4 @@ Binary
 
 .. autoclass:: category_encoders.binary.BinaryEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/catboost.rst b/docs/source/catboost.rst
index 1629ee7b..4e2e80dd 100755
--- a/docs/source/catboost.rst
+++ b/docs/source/catboost.rst
@@ -1,5 +1,6 @@
 CatBoost Encoder
-==============
+================
 
 .. autoclass:: category_encoders.cat_boost.CatBoostEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index f26c1286..66344bbe 100755
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -56,7 +56,7 @@
 
 # General information about the project.
 project = 'Category Encoders'
-copyright = '2016, Will McGinnis'
+copyright = '2022, Will McGinnis'
 author = 'Will McGinnis'
 
 # The version info for the project you're documenting, acts as replacement for
diff --git a/docs/source/count.rst b/docs/source/count.rst
index bbd0653f..56f16597 100755
--- a/docs/source/count.rst
+++ b/docs/source/count.rst
@@ -3,4 +3,5 @@ Count Encoder
 
 .. autoclass:: category_encoders.count.CountEncoder
     :members:
+    :inherited-members:
 
diff --git a/docs/source/glmm.rst b/docs/source/glmm.rst
index 3af76d54..97b3b386 100755
--- a/docs/source/glmm.rst
+++ b/docs/source/glmm.rst
@@ -3,3 +3,4 @@ Generalized Linear Mixed Model Encoder
 
 .. autoclass:: category_encoders.glmm.GLMMEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/hashing.rst b/docs/source/hashing.rst
index def7f233..13bcfc40 100755
--- a/docs/source/hashing.rst
+++ b/docs/source/hashing.rst
@@ -3,4 +3,5 @@ Hashing
 
 .. autoclass:: category_encoders.hashing.HashingEncoder
     :members:
+    :inherited-members:
 
diff --git a/docs/source/helmert.rst b/docs/source/helmert.rst
index 073c2069..118c5464 100755
--- a/docs/source/helmert.rst
+++ b/docs/source/helmert.rst
@@ -3,3 +3,4 @@ Helmert Coding
 
 .. autoclass:: category_encoders.helmert.HelmertEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 7f3e0c76..9113e9c1 100755
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -82,12 +82,12 @@ Contents:
    onehot
    ordinal
    polynomial
+   quantile
    sum
+   summary
    targetencoder
    woe
    wrapper
-   quantile
-   summary
 
 
 Indices and tables
diff --git a/docs/source/jamesstein.rst b/docs/source/jamesstein.rst
index 30b889c3..d52b9d60 100755
--- a/docs/source/jamesstein.rst
+++ b/docs/source/jamesstein.rst
@@ -1,5 +1,6 @@
 James-Stein Encoder
-==============
+===================
 
 .. autoclass:: category_encoders.james_stein.JamesSteinEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/leaveoneout.rst b/docs/source/leaveoneout.rst
index 3b8dde32..7a812681 100755
--- a/docs/source/leaveoneout.rst
+++ b/docs/source/leaveoneout.rst
@@ -3,3 +3,4 @@ Leave One Out
 
 .. autoclass:: category_encoders.leave_one_out.LeaveOneOutEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/mestimate.rst b/docs/source/mestimate.rst
index 67a27a10..86dd835a 100755
--- a/docs/source/mestimate.rst
+++ b/docs/source/mestimate.rst
@@ -3,3 +3,4 @@ M-estimate
 
 .. autoclass:: category_encoders.m_estimate.MEstimateEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/onehot.rst b/docs/source/onehot.rst
index ec1f8a13..d94f2e01 100755
--- a/docs/source/onehot.rst
+++ b/docs/source/onehot.rst
@@ -3,3 +3,4 @@ One Hot
 
 .. autoclass:: category_encoders.one_hot.OneHotEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/ordinal.rst b/docs/source/ordinal.rst
index 5316d73e..bcae6020 100755
--- a/docs/source/ordinal.rst
+++ b/docs/source/ordinal.rst
@@ -3,3 +3,4 @@ Ordinal
 
 .. autoclass:: category_encoders.ordinal.OrdinalEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/polynomial.rst b/docs/source/polynomial.rst
index 332392ce..f08b7534 100755
--- a/docs/source/polynomial.rst
+++ b/docs/source/polynomial.rst
@@ -3,3 +3,4 @@ Polynomial Coding
 
 .. autoclass:: category_encoders.polynomial.PolynomialEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/quantile.rst b/docs/source/quantile.rst
index 3f2708b1..4001e18a 100644
--- a/docs/source/quantile.rst
+++ b/docs/source/quantile.rst
@@ -1,5 +1,6 @@
 Quantile Encoder
-==============
+================
 
 .. autoclass:: category_encoders.quantile_encoder.QuantileEncoder
-    :members:
\ No newline at end of file
+    :members:
+    :inherited-members:
diff --git a/docs/source/sum.rst b/docs/source/sum.rst
index ae07a46d..55406d94 100755
--- a/docs/source/sum.rst
+++ b/docs/source/sum.rst
@@ -3,3 +3,4 @@ Sum Coding
 
 .. autoclass:: category_encoders.sum_coding.SumEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/summary.rst b/docs/source/summary.rst
index 1b1e5e89..c3907fd9 100644
--- a/docs/source/summary.rst
+++ b/docs/source/summary.rst
@@ -1,5 +1,6 @@
 Summary Encoder
-==============
+===============
 
 .. autoclass:: category_encoders.quantile_encoder.SummaryEncoder
-    :members:
\ No newline at end of file
+    :members:
+    :inherited-members:
diff --git a/docs/source/targetencoder.rst b/docs/source/targetencoder.rst
index a44340ba..84b83e08 100755
--- a/docs/source/targetencoder.rst
+++ b/docs/source/targetencoder.rst
@@ -3,3 +3,4 @@ Target Encoder
 
 .. autoclass:: category_encoders.target_encoder.TargetEncoder
     :members:
+    :inherited-members:
diff --git a/docs/source/woe.rst b/docs/source/woe.rst
index 8e3d29aa..8c0870bc 100755
--- a/docs/source/woe.rst
+++ b/docs/source/woe.rst
@@ -3,3 +3,4 @@ Weight of Evidence
 
 .. autoclass:: category_encoders.woe.WOEEncoder
     :members:
+    :inherited-members:
diff --git a/examples/grid_search_example.py b/examples/grid_search_example.py
index 9b58f5e3..dcac6187 100644
--- a/examples/grid_search_example.py
+++ b/examples/grid_search_example.py
@@ -3,7 +3,6 @@
 Tested to work with scikit-learn 0.20.2
 """
 
-from __future__ import print_function
 
 from sklearn.model_selection import GridSearchCV
 from sklearn.model_selection import train_test_split


Test patch:
diff --git a/tests/test_encoders.py b/tests/test_encoders.py
index 49b2b772..2f98f199 100644
--- a/tests/test_encoders.py
+++ b/tests/test_encoders.py
@@ -7,7 +7,7 @@
 import tests.helpers as th
 from sklearn.utils.estimator_checks import check_transformer_general, check_transformers_unfitted
 from sklearn.compose import ColumnTransformer
-from unittest import TestCase  # or `from unittest import ...` if on Python 3.4+
+from unittest import TestCase
 from copy import deepcopy
 
 import category_encoders as encoders
@@ -67,15 +67,14 @@ def test_classification(self):
                 self.assertTrue(isinstance(enc.transform(X_t), np.ndarray))
                 self.assertEqual(enc.transform(X_t).shape[0], X_t.shape[0], 'Row count must not change')
 
-                # documented in issue #122
-                # when we use the same encoder on two different datasets, it should not explode
-                # X_a = pd.DataFrame(data=['1', '2', '2', '2', '2', '2'], columns=['col_a'])
-                # X_b = pd.DataFrame(data=['1', '1', '1', '2', '2', '2'], columns=['col_b']) # different values and name
-                # y_dummy = [True, False, True, False, True, False]
-                # enc = getattr(encoders, encoder_name)()
-                # enc.fit(X_a, y_dummy)
-                # enc.fit(X_b, y_dummy)
-                # verify_numeric(enc.transform(X_b))
+                # encoders should be re-fittable (c.f. issue 122)
+                X_a = pd.DataFrame(data=['1', '2', '2', '2', '2', '2'], columns=['col_a'])
+                X_b = pd.DataFrame(data=['1', '1', '1', '2', '2', '2'], columns=['col_b']) # different values and name
+                y_dummy = [True, False, True, False, True, False]
+                enc = getattr(encoders, encoder_name)()
+                enc.fit(X_a, y_dummy)
+                enc.fit(X_b, y_dummy)
+                th.verify_numeric(enc.transform(X_b))
 
     def test_deepcopy(self):
         # Generate instance of evert encoder and test deepcopyable
@@ -86,11 +85,13 @@ def test_deepcopy(self):
                 enc2 = deepcopy(enc)
 
     def test_impact_encoders(self):
-        for encoder_name in ['LeaveOneOutEncoder', 'TargetEncoder', 'WOEEncoder', 'MEstimateEncoder', 'JamesSteinEncoder', 'CatBoostEncoder', 'GLMMEncoder', 'QuantileEncoder', 'SummaryEncoder']:
+        for encoder_name in encoders.__all__:
+            enc = getattr(encoders, encoder_name)()
+            if not enc._get_tags().get("supervised_encoder"):
+                continue
             with self.subTest(encoder_name=encoder_name):
 
                 # encode a numpy array and transform with the help of the target
-                enc = getattr(encoders, encoder_name)()
                 enc.fit(np_X, np_y)
                 th.verify_numeric(enc.transform(np_X_t, np_y_t))
 
@@ -240,14 +241,7 @@ def test_handle_unknown_value(self):
     def test_sklearn_compliance(self):
         for encoder_name in encoders.__all__:
             with self.subTest(encoder_name=encoder_name):
-
-                # in sklearn < 0.19.0, these methods require classes,
-                # in sklearn >= 0.19.0, these methods require instances
-                if sklearn.__version__ < '0.19.0':
-                    encoder = getattr(encoders, encoder_name)
-                else:
-                    encoder = getattr(encoders, encoder_name)()
-
+                encoder = getattr(encoders, encoder_name)()
                 check_transformer_general(encoder_name, encoder)
                 check_transformers_unfitted(encoder_name, encoder)
 
@@ -267,6 +261,7 @@ def test_inverse_transform(self):
 
     def test_inverse_uninitialized(self):
         # raise an error when we call inverse_transform() before the encoder is fitted
+        # @ToDo parametrize
         for encoder_name in {'BaseNEncoder', 'BinaryEncoder', 'OrdinalEncoder', 'OneHotEncoder'}:
             with self.subTest(encoder_name=encoder_name):
                 enc = getattr(encoders, encoder_name)()
@@ -275,6 +270,7 @@ def test_inverse_uninitialized(self):
     def test_inverse_wrong_feature_count(self):
         x1 = [['A', 'B', 'C'], ['D', 'E', 'F'], ['G', 'H', 'I']]
         x2 = [['A', 'B'], ['C', 'D']]
+        # @ToDo parametrize
         for encoder_name in {'BaseNEncoder', 'BinaryEncoder', 'OrdinalEncoder', 'OneHotEncoder'}:
             with self.subTest(encoder_name=encoder_name):
                 enc = getattr(encoders, encoder_name)()
@@ -284,6 +280,7 @@ def test_inverse_wrong_feature_count(self):
     def test_inverse_wrong_feature_count_drop_invariant(self):
         x1 = [['A', 'B', 'C'], ['D', 'E', 'F'], ['G', 'H', 'I']]
         x2 = [['A', 'B'], ['C', 'D']]
+        # @ToDo parametrize
         for encoder_name in {'BaseNEncoder', 'BinaryEncoder', 'OrdinalEncoder', 'OneHotEncoder'}:
             with self.subTest(encoder_name=encoder_name):
                 enc = getattr(encoders, encoder_name)(drop_invariant=True)
@@ -294,6 +291,7 @@ def test_inverse_numeric(self):
         x = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
         y = [0, 0, 1]
 
+        # @ToDo parametrize
         for encoder_name in {'BaseNEncoder', 'BinaryEncoder', 'OrdinalEncoder', 'OneHotEncoder'}:
             with self.subTest(encoder_name=encoder_name):
                 enc = getattr(encoders, encoder_name)()
@@ -303,6 +301,7 @@ def test_inverse_numeric(self):
 
     def test_inverse_numpy(self):
         # See issue #196
+        # @ToDo parametrize
         for encoder_name in {'BaseNEncoder', 'BinaryEncoder', 'OrdinalEncoder', 'OneHotEncoder'}:
             with self.subTest(encoder_name=encoder_name):
                 arr = np.array([['A'], ['B'], ['B'], ['C']])
@@ -358,11 +357,12 @@ def test_tmp_column_name(self):
             {'Trend': ['UP', 'UP', 'DOWN', 'FLAT'],
              'Trend_tmp': ['UP', 'UP', 'DOWN', 'FLAT'],
              'target': [1, 1, 0, 0]}, columns=['Trend', 'Trend_tmp', 'target'])
-
-        for encoder_name in ['LeaveOneOutEncoder', 'TargetEncoder', 'WOEEncoder', 'MEstimateEncoder', 'JamesSteinEncoder', 'CatBoostEncoder', 'GLMMEncoder']:
+        for encoder_name in encoders.__all__:
+            enc = getattr(encoders, encoder_name)()
+            if not enc._get_tags().get('supervised_encoder'):
+                continue
             with self.subTest(encoder_name=encoder_name):
-                encoder = getattr(encoders, encoder_name)()
-                _ = encoder.fit_transform(binary_cat_example, binary_cat_example['target'])
+                _ = enc.fit_transform(binary_cat_example, binary_cat_example['target'])
 
     def test_preserve_names(self):
         binary_cat_example = pd.DataFrame(
@@ -379,10 +379,14 @@ def test_preserve_names(self):
                 self.assertTrue('ignore' in columns, "Column 'ignore' is missing in: " + str(columns))
 
     def test_unique_column_is_not_predictive(self):
-        for encoder_name in ['LeaveOneOutEncoder', 'TargetEncoder', 'WOEEncoder', 'MEstimateEncoder', 'JamesSteinEncoder', 'CatBoostEncoder', 'GLMMEncoder']:
-            with self.subTest(encoder_name=encoder_name):
-                encoder = getattr(encoders, encoder_name)()
-                result = encoder.fit_transform(X[['unique_str']], y)
+        # @ToDo not sure how useful this test is. TargetEncoders set the value to the default if there is only
+        #  one category but they probably should not. See discussion in issue 327
+        test_encoders = ['LeaveOneOutEncoder', 'TargetEncoder', 'WOEEncoder', 'MEstimateEncoder',
+                         'JamesSteinEncoder', 'CatBoostEncoder', 'GLMMEncoder']
+        for encoder_name in test_encoders:
+            enc = getattr(encoders, encoder_name)()
+            with self.subTest(encoder_name=encoder_name):
+                result = enc.fit_transform(X[['unique_str']], y)
                 self.assertTrue(all(result.var() < 0.001), 'The unique string column must not be predictive of the label')
 
     # # beware: for some reason doctest does not raise exceptions - you have to read the text output
@@ -444,12 +448,12 @@ def test_get_feature_names(self):
             with self.subTest(encoder_name=encoder_name):
                 enc = getattr(encoders, encoder_name)()
                 # Target encoders also need y
-                if encoder_name not in ['TargetEncoder', 'WOEEncoder', 'LeaveOneOutEncoder', 'MEstimateEncoder', 'JamesSteinEncoder', 'CatBoostEncoder', 'GLMMEncoder', 'QuantileEncoder', 'SummaryEncoder']:
-                    obtained = enc.fit(X).get_feature_names()
-                    expected = enc.transform(X).columns.tolist()
-                else:
+                if enc._get_tags().get('supervised_encoder'):
                     obtained = enc.fit(X, y).get_feature_names()
                     expected = enc.transform(X, y).columns.tolist()
+                else:
+                    obtained = enc.fit(X).get_feature_names()
+                    expected = enc.transform(X).columns.tolist()
                 self.assertEqual(obtained, expected)
 
     def test_get_feature_names_drop_invariant(self):
@@ -459,12 +463,12 @@ def test_get_feature_names_drop_invariant(self):
             with self.subTest(encoder_name=encoder_name):
                 enc = getattr(encoders, encoder_name)(drop_invariant=True)
                 # Target encoders also need y
-                if encoder_name not in ['TargetEncoder', 'WOEEncoder', 'LeaveOneOutEncoder', 'MEstimateEncoder', 'JamesSteinEncoder', 'CatBoostEncoder', 'KFoldEncoder', 'GLMMEncoder', 'QuantileEncoder', 'SummaryEncoder']:
-                    obtained = enc.fit(X).get_feature_names()
-                    expected = enc.transform(X).columns.tolist()
-                else:
+                if enc._get_tags().get('supervised_encoder'):
                     obtained = enc.fit(X, y).get_feature_names()
                     expected = enc.transform(X, y).columns.tolist()
+                else:
+                    obtained = enc.fit(X).get_feature_names()
+                    expected = enc.transform(X).columns.tolist()
                 self.assertEqual(obtained, expected)
 
     def test_get_feature_names_not_set(self):
@@ -496,23 +500,23 @@ def test_truncated_index(self):
 
     def test_column_transformer(self):
         # see issue #169
-            for encoder_name in (set(encoders.__all__) - {'HashingEncoder'}):  # HashingEncoder does not accept handle_missing parameter
-                with self.subTest(encoder_name=encoder_name):
+        for encoder_name in (set(encoders.__all__) - {'HashingEncoder'}):  # HashingEncoder does not accept handle_missing parameter
+            with self.subTest(encoder_name=encoder_name):
 
-                    # we can only test one data type at once. Here, we test string columns.
-                    tested_columns = ['unique_str', 'invariant', 'underscore', 'none', 'extra']
+                # we can only test one data type at once. Here, we test string columns.
+                tested_columns = ['unique_str', 'invariant', 'underscore', 'none', 'extra']
 
-                    # ColumnTransformer instantiates the encoder twice -> we have to make sure the encoder settings are correctly passed
-                    ct = ColumnTransformer([
-                        ("dummy_encoder_name", getattr(encoders, encoder_name)(handle_missing="return_nan"), tested_columns)
-                    ])
-                    obtained = ct.fit_transform(X, y)
+                # ColumnTransformer instantiates the encoder twice -> we have to make sure the encoder settings are correctly passed
+                ct = ColumnTransformer([
+                    ("dummy_encoder_name", getattr(encoders, encoder_name)(handle_missing="return_nan"), tested_columns)
+                ])
+                obtained = ct.fit_transform(X, y)
 
-                    # the old-school approach
-                    enc = getattr(encoders, encoder_name)(handle_missing="return_nan", return_df=False)
-                    expected = enc.fit_transform(X[tested_columns], y)
+                # the old-school approach
+                enc = getattr(encoders, encoder_name)(handle_missing="return_nan", return_df=False)
+                expected = enc.fit_transform(X[tested_columns], y)
 
-                    np.testing.assert_array_equal(obtained, expected)
+                np.testing.assert_array_equal(obtained, expected)
 
     def test_error_messages(self):
         # when the count of features change between training and scoring, we raise an exception
@@ -528,15 +532,14 @@ def test_error_messages(self):
         x = ['A', 'B', 'C']
         y_good = pd.Series([1, 0, 1])
         y_bad = pd.Series([1, 0, 1, 0])
-        for encoder_name in ['LeaveOneOutEncoder', 'TargetEncoder', 'WOEEncoder', 'MEstimateEncoder', 'JamesSteinEncoder', 'CatBoostEncoder', 'GLMMEncoder', 'QuantileEncoder', 'SummaryEncoder']:
+        for encoder_name in encoders.__all__:
+            enc = getattr(encoders, encoder_name)()
+            if not enc._get_tags().get('supervised_encoder'):
+                continue
             with self.subTest(encoder_name=encoder_name):
-                enc = getattr(encoders, encoder_name)()
                 self.assertRaises(ValueError, enc.fit, x, y_bad)
 
-        # ...and scoring. Otherwise they raise an error of ValueError type.
-        for encoder_name in ['LeaveOneOutEncoder', 'TargetEncoder', 'WOEEncoder', 'MEstimateEncoder', 'JamesSteinEncoder', 'CatBoostEncoder', 'GLMMEncoder', 'QuantileEncoder', 'SummaryEncoder']:
             with self.subTest(encoder_name=encoder_name):
-                enc = getattr(encoders, encoder_name)()
                 enc.fit(x, y_good)
                 self.assertRaises(ValueError, enc.transform, x, y_bad)
 
@@ -556,7 +559,10 @@ def test_drop_invariant(self):
 
     def test_target_encoders(self):
         # See issue #206
-        for encoder_name in {'CatBoostEncoder', 'JamesSteinEncoder', 'LeaveOneOutEncoder', 'MEstimateEncoder', 'TargetEncoder', 'WOEEncoder', 'GLMMEncoder'}:
+        for encoder_name in encoders.__all__:
+            enc = getattr(encoders, encoder_name)()
+            if not enc._get_tags().get('supervised_encoder'):
+                continue
             with self.subTest(encoder_name=encoder_name):
                 enc = getattr(encoders, encoder_name)(return_df=False)
                 # an attempt to fit_transform() a supervised encoder without the target should result into a meaningful error message
diff --git a/tests/test_glmm.py b/tests/test_glmm.py
index 9d8442f8..fef9c216 100644
--- a/tests/test_glmm.py
+++ b/tests/test_glmm.py
@@ -3,9 +3,6 @@
 import category_encoders as encoders
 import tests.helpers as th
 
-# data definitions
-# X = th.create_dataset(n_rows=100)
-# np_y = np.random.default_rng(42).standard_normal(100) > 0.5
 
 class TestGLMMEncoder(TestCase):
     def test_continuous(self):
diff --git a/tests/test_ordinal.py b/tests/test_ordinal.py
index 239228eb..f01f3cc0 100644
--- a/tests/test_ordinal.py
+++ b/tests/test_ordinal.py
@@ -5,37 +5,38 @@
 import category_encoders as encoders
 
 
-class TestOrdinalEncoder(TestCase):
+np_X = th.create_array(n_rows=100)
+np_X_t = th.create_array(n_rows=50, extras=True)
+np_y = np.random.randn(np_X.shape[0]) > 0.5
+np_y_t = np.random.randn(np_X_t.shape[0]) > 0.5
+X = th.create_dataset(n_rows=100)
+X_t = th.create_dataset(n_rows=50, extras=True)
+y = pd.DataFrame(np_y)
+y_t = pd.DataFrame(np_y_t)
+
 
-    def setUp(self) -> None:
-        np_X = th.create_array(n_rows=100)
-        np_X_t = th.create_array(n_rows=50, extras=True)
-        np_y = np.random.randn(np_X.shape[0]) > 0.5
-        np_y_t = np.random.randn(np_X_t.shape[0]) > 0.5
-        self.X = th.create_dataset(n_rows=100)
-        self.X_t = th.create_dataset(n_rows=50, extras=True)
-        self.y = pd.DataFrame(np_y)
-        self.y_t = pd.DataFrame(np_y_t)
+class TestOrdinalEncoder(TestCase):
 
     def test_ordinal(self):
+
         enc = encoders.OrdinalEncoder(verbose=1, return_df=True)
-        enc.fit(self.X)
-        out = enc.transform(self.X_t)
+        enc.fit(X)
+        out = enc.transform(X_t)
         self.assertEqual(len(set(out['extra'].values)), 4)
         self.assertIn(-1, set(out['extra'].values))
         self.assertFalse(enc.mapping is None)
         self.assertTrue(len(enc.mapping) > 0)
 
         enc = encoders.OrdinalEncoder(verbose=1, mapping=enc.mapping, return_df=True)
-        enc.fit(self.X)
-        out = enc.transform(self.X_t)
+        enc.fit(X)
+        out = enc.transform(X_t)
         self.assertEqual(len(set(out['extra'].values)), 4)
         self.assertIn(-1, set(out['extra'].values))
         self.assertTrue(len(enc.mapping) > 0)
 
         enc = encoders.OrdinalEncoder(verbose=1, return_df=True, handle_unknown='return_nan')
-        enc.fit(self.X)
-        out = enc.transform(self.X_t)
+        enc.fit(X)
+        out = enc.transform(X_t)
         out_cats = [x for x in set(out['extra'].values) if np.isfinite(x)]
         self.assertEqual(len(out_cats), 3)
         self.assertFalse(enc.mapping is None)
@@ -240,13 +241,12 @@ def test_inverse_transform_HaveHandleMissingValueAndHandleUnknownReturnNan_Expec
         pd.testing.assert_frame_equal(expected, original)
 
     def test_inverse_with_mapping(self):
-        df = self.X.copy(deep=True)
+        df = X.copy(deep=True)
         categoricals = ['unique_int', 'unique_str', 'invariant', 'underscore', 'none', 'extra', 321]
-        mapping = [{'col': c, 'mapping': pd.Series(data=range(len(df[c].unique())), index=df[c].unique()),
-                    'data_type': self.X[c].dtype} for c in categoricals]
+        mapping = [{'col': c, 'mapping': pd.Series(data=range(len(df[c].unique())), index=df[c].unique()), 'data_type': X[c].dtype} for c in categoricals]
         enc = encoders.OrdinalEncoder(cols=categoricals, handle_unknown='ignore', mapping=mapping, return_df=True)
         df[categoricals] = enc.fit_transform(df[categoricals])
 
         recovered = enc.inverse_transform(df[categoricals])
 
-        pd.testing.assert_frame_equal(self.X[categoricals], recovered)
+        pd.testing.assert_frame_equal(X[categoricals], recovered)
diff --git a/tests/test_target_encoder.py b/tests/test_target_encoder.py
index b31336da..cb0e531c 100644
--- a/tests/test_target_encoder.py
+++ b/tests/test_target_encoder.py
@@ -17,7 +17,6 @@ def test_target_encoder(self):
         X_t = th.create_dataset(n_rows=50, extras=True)
         y = pd.DataFrame(np_y)
         y_t = pd.DataFrame(np_y_t)
-
         enc = encoders.TargetEncoder(verbose=1, smoothing=2, min_samples_leaf=2)
         enc.fit(X, y)
         th.verify_numeric(enc.transform(X_t))
diff --git a/tests/test_woe.py b/tests/test_woe.py
index 52082006..b34a55e5 100644
--- a/tests/test_woe.py
+++ b/tests/test_woe.py
@@ -5,17 +5,18 @@
 
 import category_encoders as encoders
 
+np_X = th.create_array(n_rows=100)
+np_X_t = th.create_array(n_rows=50, extras=True)
+np_y = np.random.randn(np_X.shape[0]) > 0.5
+np_y_t = np.random.randn(np_X_t.shape[0]) > 0.5
+X = th.create_dataset(n_rows=100)
+X_t = th.create_dataset(n_rows=50, extras=True)
+y = pd.DataFrame(np_y)
+y_t = pd.DataFrame(np_y_t)
+
 
 class TestWeightOfEvidenceEncoder(TestCase):
     def test_woe(self):
-        np_X = th.create_array(n_rows=100)
-        np_X_t = th.create_array(n_rows=50, extras=True)
-        np_y = np.random.randn(np_X.shape[0]) > 0.5
-        np_y_t = np.random.randn(np_X_t.shape[0]) > 0.5
-        X = th.create_dataset(n_rows=100)
-        X_t = th.create_dataset(n_rows=50, extras=True)
-        y = pd.DataFrame(np_y)
-        y_t = pd.DataFrame(np_y_t)
         cols = ['unique_str', 'underscore', 'extra', 'none', 'invariant', 321, 'categorical', 'na_categorical', 'categorical_int']
 
         # balanced label with balanced features