Repository: dirty-cat/dirty_cat

Issue:
GapEncoder only supports 1D matrices
Hi

Currently, the GapEncoder only supports column vectors, however, it should accept 2D matrices in order to comply with scikit-learn's conventions.

Code:

```python3

import numpy as np
import pandas as pd

from dirty_cat import GapEncoder

# Data from `employee_salaries`
col_vec = np.array(['Department of Police', 'Department of Police', 'Department of Health and Human Services',
                    'Correction and Rehabilitation', 'Department of Housing and Community Affairs',
                    'Department of Police', 'Fire and Rescue Services'])
col_vec = col_vec.reshape(col_vec.shape[0], 1)
row_vec = col_vec.transpose()  # Convert to row vector
two_d = pd.DataFrame([
    ['Department of Police', 'Department of Police', 'Department of Health and Human Services',
     'Correction and Rehabilitation', 'Department of Housing and Community Affairs', 'Department of Police',
     'Fire and Rescue Services'],
    ['MSB Information Mgmt and Tech Division Records Management Section', 
     'ISB Major Crimes Division Fugitive Section', 'Adult Protective and Case Management Services', 
     'Affordable Housing Programs', 'PRRS Facility and Security', 'PSB 6th District Special Assignment Team', 
     'EMS Billing']
])

print(f'{col_vec.shape=} ; {row_vec.shape=} ; {two_d.shape=}')

gap = GapEncoder()
col_vec_fitted = gap.fit(col_vec)  # Works
row_vec_fitted = gap.fit(row_vec)  # Doesn't work
two_d_fitted = gap.fit(two_d)  # Doesn't work

```

Ouput:

```python3

col_vec.shape=(7, 1) ; row_vec.shape=(1, 7) ; two_d.shape=(2, 7)

Traceback (most recent call last):
  File "dirty_cat/temp/try_gap.py", line 29, in <module>
    row_vec_fitted = gap.fit(row_vec)  # Doesn't work
  File "dirty_cat/dirty_cat/gap_encoder.py", line 295, in fit
    assert X.ndim == 1 or (X.ndim == 2 and X.shape[1] == 1), f"ERROR:\
AssertionError: ERROR:        shape (1, 7) of input array is not supported.

```

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index f2ae20f..e73e914 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -21,6 +21,11 @@ Major changes
   for scikit-learn's `ColumnTransformer` simpler to use on heterogeneous
   panda dataframes.
 
+* **Backward incompatible change to GapEncoder**: The GapEncoder now only
+  supports two-dimensional inputs of shape (n_samples, n_features).
+  Internally, features are encoded by independent GapEncoder models,
+  and are then concatenated into a single matrix.
+
 
 Bug-fixes
 ---------
diff --git a/dirty_cat/gap_encoder.py b/dirty_cat/gap_encoder.py
index 2caf5ef..b27b159 100644
--- a/dirty_cat/gap_encoder.py
+++ b/dirty_cat/gap_encoder.py
@@ -24,6 +24,8 @@ from sklearn.base import BaseEstimator, TransformerMixin
 from sklearn.feature_extraction.text import CountVectorizer, HashingVectorizer
 from sklearn.cluster import KMeans
 from sklearn.neighbors import NearestNeighbors
+import pandas as pd
+from .utils import check_input
 
 if LooseVersion(sklearn_version) < LooseVersion('0.22'):
     from sklearn.cluster.k_means_ import _k_init
@@ -38,99 +40,9 @@ else:
     from sklearn.decomposition._nmf import _beta_divergence
 
 
-class GapEncoder(BaseEstimator, TransformerMixin):
-    """
-    This encoder can be understood as a continuous encoding on a set of latent
-    categories estimated from the data. The latent categories are built by
-    capturing combinations of substrings that frequently co-occur.
-    
-    The GapEncoder supports online learning on batches of data for
-    scalability through the partial_fit method.
-
-    Parameters
-    ----------
-
-    n_components : int, default=10
-        Number of latent categories used to model string data.
-
-    batch_size : int, default=128
-        Number of samples per batch.
-
-    gamma_shape_prior : float, default=1.1
-        Shape parameter for the Gamma prior distribution.
-
-    gamma_scale_prior : float, default=1.0
-        Scale parameter for the Gamma prior distribution.
-
-    rho : float, default=0.95
-        Weight parameter for the update of the W matrix.
-    
-    rescale_rho : bool, default=False
-        If true, use rho ** (batch_size / len(X)) instead of rho to obtain an
-        update rate per iteration that is independent of the batch size.
-
-    hashing : bool, default=False
-        If true, HashingVectorizer is used instead of CountVectorizer.
-        It has the advantage of being very low memory scalable to large
-        datasets as there is no need to store a vocabulary dictionary in
-        memory.
-
-    hashing_n_features : int, default=2**12
-        Number of features for the HashingVectorizer. Only relevant if
-        hashing=True.
-
-    init : str, default='k-means++'
-        Initialization method of the W matrix.
-        Options: {'k-means++', 'random', 'k-means'}.
-        If init='k-means++', we use the init method of sklearn.cluster.KMeans.
-        If init='random', topics are initialized with a Gamma distribution.
-        If init='k-means', topics are initialized with a KMeans on the n-grams
-        counts. This usually makes convergence faster but is a bit slower.
-
-    tol : float, default=1e-4
-        Tolerance for the convergence of the matrix W.
-
-    min_iter : int, default=2
-        Minimum number of iterations on the input data.
-
-    max_iter : int, default=5
-        Maximum number of iterations on the input data.
+class GapEncoderColumn(BaseEstimator, TransformerMixin):
 
-    ngram_range : tuple, default=(2, 4)
-        The range of ngram length that will be used to build the
-        bag-of-n-grams representation of the input data.
-
-    analyzer : str, default='char'.
-        Analyzer parameter for the CountVectorizer/HashingVectorizer.
-        Options: {‘word’, ‘char’, ‘char_wb’}, describing whether the matrix V
-        to factorize should be made of word counts or character n-gram counts.
-        Option ‘char_wb’ creates character n-grams only from text inside word
-        boundaries; n-grams at the edges of words are padded with space.
-
-    add_words : bool, default=False
-        If true, add the words counts to the bag-of-n-grams representation
-        of the input data.
-
-    random_state : int or None, default=None
-        Pass an int for reproducible output across multiple function calls.
-
-    rescale_W : bool, default=True
-        If true, the weight matrix W is rescaled at each iteration
-        to have an l1 norm equal to 1 for each row.
-    
-    max_iter_e_step : int, default=20
-        Maximum number of iterations to adjust the activations h at each step.
-
-
-    Attributes
-    ----------
-
-    References
-    ----------
-    For a detailed description of the method, see
-    `Encoding high-cardinality string categorical variables
-    <https://hal.inria.fr/hal-02171256v4>`_ by Cerda, Varoquaux (2019).
-    """
+    """See GapEncoder's docstring."""
 
     def __init__(self, n_components=10, batch_size=128, gamma_shape_prior=1.1,
                  gamma_scale_prior=1.0, rho=.95, rescale_rho=False,
@@ -283,19 +195,13 @@ class GapEncoder(BaseEstimator, TransformerMixin):
 
         Parameters
         ----------
-        X : array-like, shape (n_samples, ) or (n_samples, 1)
+        X : array-like, shape (n_samples, )
             The string data to fit the model on.
         
         Returns
         -------
         self
         """
-        # Check input data shape
-        X = np.asarray(X)
-        assert X.ndim == 1 or (X.ndim == 2 and X.shape[1] == 1), f"ERROR:\
-        shape {X.shape} of input array is not supported."
-        if X.ndim == 2:
-            X = X[:, 0]
         # Check if first item has str or np.str_ type
         assert isinstance(X[0], str), "ERROR: Input data is not string."
         # Make n-grams counts matrix unq_V
@@ -335,8 +241,7 @@ class GapEncoder(BaseEstimator, TransformerMixin):
         self.H_dict_.update(zip(unq_X, unq_H))
         return self
 
-    def get_feature_names(self, n_labels=3):
-        
+    def get_feature_names(self, n_labels=3, prefix=''):
         """
         Returns the labels that best summarize the learned components/topics.
         For each topic, labels with highest activations are selected.
@@ -354,7 +259,6 @@ class GapEncoder(BaseEstimator, TransformerMixin):
             The labels that best describe each topic.
         
         """
-        
         vectorizer = CountVectorizer()
         vectorizer.fit(list(self.H_dict_.keys()))
         vocabulary = np.array(vectorizer.get_feature_names())
@@ -367,7 +271,7 @@ class GapEncoder(BaseEstimator, TransformerMixin):
             x = encoding[:, i]
             labels = vocabulary[np.argsort(-x)[:n_labels]]
             topic_labels.append(labels)
-        topic_labels = [', '.join(label) for label in topic_labels]
+        topic_labels = [prefix + ', '.join(label) for label in topic_labels]
         return topic_labels
 
     def score(self, X):
@@ -418,7 +322,7 @@ class GapEncoder(BaseEstimator, TransformerMixin):
 
         Parameters
         ----------
-        X : array-like, shape (n_samples, ) or (n_samples, 1)
+        X : array-like, shape (n_samples, )
             The string data to fit the model on.
         
         Returns
@@ -430,12 +334,6 @@ class GapEncoder(BaseEstimator, TransformerMixin):
         # Init H_dict_ with empty dict if it's the first call of partial_fit
         if not hasattr(self, 'H_dict_'):
             self.H_dict_ = dict()
-        # Check input data shape
-        X = np.asarray(X)
-        assert X.ndim == 1 or (X.ndim == 2 and X.shape[1] == 1), f"ERROR:\
-        shape {X.shape} of input array is not supported."
-        if X.ndim == 2:
-            X = X[:, 0]
         # Check if first item has str or np.str_ type
         assert isinstance(X[0], str), "ERROR: Input data is not string."
         # Check if it is not the first batch
@@ -500,7 +398,7 @@ class GapEncoder(BaseEstimator, TransformerMixin):
 
         Parameters
         ----------
-        X : array-like, shape (n_samples, ) or (n_samples, 1)
+        X : array-like, shape (n_samples, n_features)
             The string data to encode.
 
         Returns
@@ -508,12 +406,6 @@ class GapEncoder(BaseEstimator, TransformerMixin):
         H : 2-d array, shape (n_samples, n_topics)
             Transformed input.
         """
-        # Check input data shape
-        X = np.asarray(X)
-        assert X.ndim == 1 or (X.ndim == 2 and X.shape[1] == 1), f"ERROR:\
-        shape {X.shape} of input array is not supported."
-        if X.ndim == 2:
-            X = X[:, 0]
         # Check if first item has str or np.str_ type
         assert isinstance(X[0], str), "ERROR: Input data is not string."
         unq_X = np.unique(X)
@@ -540,6 +432,299 @@ class GapEncoder(BaseEstimator, TransformerMixin):
         return self._get_H(X)
 
 
+class GapEncoder(BaseEstimator, TransformerMixin):
+    """
+    This encoder can be understood as a continuous encoding on a set of latent
+    categories estimated from the data. The latent categories are built by
+    capturing combinations of substrings that frequently co-occur.
+
+    The GapEncoder supports online learning on batches of data for
+    scalability through the partial_fit method.
+
+    Parameters
+    ----------
+
+    n_components : int, default=10
+        Number of latent categories used to model string data.
+
+    batch_size : int, default=128
+        Number of samples per batch.
+
+    gamma_shape_prior : float, default=1.1
+        Shape parameter for the Gamma prior distribution.
+
+    gamma_scale_prior : float, default=1.0
+        Scale parameter for the Gamma prior distribution.
+
+    rho : float, default=0.95
+        Weight parameter for the update of the W matrix.
+
+    rescale_rho : bool, default=False
+        If true, use rho ** (batch_size / len(X)) instead of rho to obtain an
+        update rate per iteration that is independent of the batch size.
+
+    hashing : bool, default=False
+        If true, HashingVectorizer is used instead of CountVectorizer.
+        It has the advantage of being very low memory scalable to large
+        datasets as there is no need to store a vocabulary dictionary in
+        memory.
+
+    hashing_n_features : int, default=2**12
+        Number of features for the HashingVectorizer. Only relevant if
+        hashing=True.
+
+    init : str, default='k-means++'
+        Initialization method of the W matrix.
+        Options: {'k-means++', 'random', 'k-means'}.
+        If init='k-means++', we use the init method of sklearn.cluster.KMeans.
+        If init='random', topics are initialized with a Gamma distribution.
+        If init='k-means', topics are initialized with a KMeans on the n-grams
+        counts. This usually makes convergence faster but is a bit slower.
+
+    tol : float, default=1e-4
+        Tolerance for the convergence of the matrix W.
+
+    min_iter : int, default=2
+        Minimum number of iterations on the input data.
+
+    max_iter : int, default=5
+        Maximum number of iterations on the input data.
+
+    ngram_range : tuple, default=(2, 4)
+        The range of ngram length that will be used to build the
+        bag-of-n-grams representation of the input data.
+
+    analyzer : str, default='char'.
+        Analyzer parameter for the CountVectorizer/HashingVectorizer.
+        Options: {‘word’, ‘char’, ‘char_wb’}, describing whether the matrix V
+        to factorize should be made of word counts or character n-gram counts.
+        Option ‘char_wb’ creates character n-grams only from text inside word
+        boundaries; n-grams at the edges of words are padded with space.
+
+    add_words : bool, default=False
+        If true, add the words counts to the bag-of-n-grams representation
+        of the input data.
+
+    random_state : int or None, default=None
+        Pass an int for reproducible output across multiple function calls.
+
+    rescale_W : bool, default=True
+        If true, the weight matrix W is rescaled at each iteration
+        to have an l1 norm equal to 1 for each row.
+
+    max_iter_e_step : int, default=20
+        Maximum number of iterations to adjust the activations h at each step.
+
+
+    Attributes
+    ----------
+
+    References
+    ----------
+    For a detailed description of the method, see
+    `Encoding high-cardinality string categorical variables
+    <https://hal.inria.fr/hal-02171256v4>`_ by Cerda, Varoquaux (2019).
+    
+    """
+
+    def __init__(self, n_components=10, batch_size=128, gamma_shape_prior=1.1,
+                 gamma_scale_prior=1.0, rho=.95, rescale_rho=False,
+                 hashing=False, hashing_n_features=2**12, init='k-means++',
+                 tol=1e-4, min_iter=2, max_iter=5, ngram_range=(2, 4),
+                 analyzer='char', add_words=False, random_state=None,
+                 rescale_W=True, max_iter_e_step=20):
+
+        self.ngram_range = ngram_range
+        self.n_components = n_components
+        self.gamma_shape_prior = gamma_shape_prior  # 'a' parameter
+        self.gamma_scale_prior = gamma_scale_prior  # 'b' parameter
+        self.rho = rho
+        self.rho_ = self.rho
+        self.rescale_rho = rescale_rho
+        self.batch_size = batch_size
+        self.tol = tol
+        self.hashing = hashing
+        self.hashing_n_features = hashing_n_features
+        self.max_iter = max_iter
+        self.min_iter = min_iter
+        self.init = init
+        self.analyzer = analyzer
+        self.add_words = add_words
+        self.random_state = random_state
+        self.rescale_W = rescale_W
+        self.max_iter_e_step = max_iter_e_step
+
+    def _create_column_gap_encoder(self) -> GapEncoderColumn:
+        return GapEncoderColumn(
+            ngram_range=self.ngram_range,
+            n_components=self.n_components,
+            gamma_shape_prior=self.gamma_shape_prior,
+            gamma_scale_prior=self.gamma_scale_prior,
+            rho=self.rho,
+            rescale_rho=self.rescale_rho,
+            batch_size=self.batch_size,
+            tol=self.tol,
+            hashing=self.hashing,
+            hashing_n_features=self.hashing_n_features,
+            max_iter=self.max_iter,
+            init=self.init,
+            add_words=self.add_words,
+            random_state=self.random_state,
+            rescale_W=self.rescale_W,
+            max_iter_e_step=self.max_iter_e_step,
+        )
+            
+    def fit(self, X, y=None):
+        """
+        Fit the GapEncoder on batches of X.
+
+        Parameters
+        ----------
+        X : array-like, shape (n_samples, n_features)
+            The string data to fit the model on.
+        
+        Returns
+        -------
+        self
+        
+        """
+        # If X is a dataframe, store its column names
+        if isinstance(X, pd.DataFrame):
+            self.column_names_ = list(X.columns)
+        # Check input data shape
+        X = check_input(X)
+        self.fitted_models_ = []
+        for k in range(X.shape[1]):
+            col_enc = self._create_column_gap_encoder()
+            self.fitted_models_.append(col_enc.fit(X[:, k]))
+        return self
+
+    def transform(self, X):
+        """
+        Return the encoded vectors (activations) H of input strings in X.
+        Given the learnt topics W, the activations H are tuned to fit V = HW.
+        When X has several columns, they are encoded separately and
+        then concatenated.
+        
+        Remark: calling transform mutliple times in a row on the same
+        input X can give slightly different encodings. This is expected
+        due to a caching mechanism to speed things up.        
+
+        Parameters
+        ----------
+        X : array-like, shape (n_samples, n_features)
+            The string data to encode.
+
+        Returns
+        -------
+        H : 2-d array, shape (n_samples, n_topics * n_features)
+            Transformed input.
+        """
+        # Check input data shape
+        X = check_input(X)
+        X_enc = []
+        for k in range(X.shape[1]):
+            X_enc.append(self.fitted_models_[k].transform(X[:, k]))
+        X_enc = np.hstack(X_enc)
+        return X_enc
+
+    def partial_fit(self, X, y=None):
+        """
+        Partial fit of the GapEncoder on X.
+        To be used in a online learning procedure where batches of data are
+        coming one by one.
+
+        Parameters
+        ----------
+        X : array-like, shape (n_samples, n_features)
+            The string data to fit the model on.
+        
+        Returns
+        -------
+        self
+        
+        """
+        # If X is a dataframe, store its column names
+        if isinstance(X, pd.DataFrame):
+            self.column_names_ = list(X.columns)
+        # Check input data shape
+        X = check_input(X)
+        # Init the `GapEncoderColumn` instances if the model was
+        # not fitted already.
+        if not hasattr(self, 'fitted_models_'):
+            self.fitted_models_ = [
+                self._create_column_gap_encoder() for _ in range(X.shape[1])
+            ]
+        for k in range(X.shape[1]):
+            self.fitted_models_[k].partial_fit(X[:, k])
+        return self
+
+    def get_feature_names(self, col_names=None, n_labels=3):
+        """
+        Returns the labels that best summarize the learned components/topics.
+        For each topic, labels with highest activations are selected.
+        
+        Parameters
+        ----------
+        
+        col_names : {None, list or str}, default=None
+            The column names to be added as prefixes before the labels.
+            If col_names == None, no prefixes are used.
+            If col_names == 'auto', column names are automatically defined:
+                - if the input data was a dataframe, its column names are used
+                - otherwise, 'col1', ..., 'colN' are used as prefixes
+            Prefixes can be manually set by passing a list  for col_names.
+            
+        n_labels : int, default=3
+            The number of labels used to describe each topic.
+        
+        Returns
+        -------
+        
+        topic_labels : list of strings
+            The labels that best describe each topic.
+        
+        """
+        assert hasattr(self, 'fitted_models_'), (
+            'ERROR: GapEncoder must be fitted first.')
+        # Generate prefixes
+        if isinstance(col_names, list):
+            prefixes = [s + ': ' for s in col_names]
+        elif col_names == 'auto':
+            if hasattr(self, 'column_names_'): # Use column names
+                prefixes = [s + ': ' for s in self.column_names_]
+            else: # Use 'col1: ', ... 'colN: ' as prefixes
+                prefixes = [f'col{k}: ' for k in range(len(self.fitted_models_))]
+        else: # Empty prefixes
+            prefixes = [''] * len(self.fitted_models_)
+        labels = list()
+        for k, enc in enumerate(self.fitted_models_):
+            col_labels = enc.get_feature_names(n_labels, prefixes[k])
+            labels.extend(col_labels)
+        return labels
+
+    def score(self, X):
+        """
+        Returns the sum over the columns of X of the Kullback-Leibler
+        divergence between the n-grams counts matrix V of X, and its
+        non-negative factorization HW.
+
+        Parameters
+        ----------
+        X : array-like (str), shape (n_samples, n_features)
+            The data to encode.
+
+        Returns
+        -------
+        kl_divergence : float.
+            The Kullback-Leibler divergence.
+        """
+        X = check_input(X)
+        kl_divergence = 0
+        for k in range(X.shape[1]):
+            kl_divergence += self.fitted_models_[k].score(X[:,k])
+        return kl_divergence
+        
 def _rescale_W(W, A):
     """
     Rescale the topics W to have a L1-norm equal to 1.
diff --git a/dirty_cat/super_vectorizer.py b/dirty_cat/super_vectorizer.py
index 9d0ac24..84b39c9 100644
--- a/dirty_cat/super_vectorizer.py
+++ b/dirty_cat/super_vectorizer.py
@@ -21,7 +21,7 @@ from sklearn.base import BaseEstimator
 from sklearn.compose import ColumnTransformer
 from sklearn.preprocessing import OneHotEncoder
 
-from dirty_cat import SimilarityEncoder
+from dirty_cat import GapEncoder
 
 
 def _has_missing_values(array: pd.DataFrame) -> bool:
@@ -139,9 +139,9 @@ class SuperVectorizer(ColumnTransformer):
     def __init__(self, *,
                  cardinality_threshold: int = 20,
                  low_card_str_transformer: Optional[Union[BaseEstimator, str]] = OneHotEncoder(),
-                 high_card_str_transformer: Optional[Union[BaseEstimator, str]] = SimilarityEncoder(),
+                 high_card_str_transformer: Optional[Union[BaseEstimator, str]] = GapEncoder(),
                  low_card_cat_transformer: Optional[Union[BaseEstimator, str]] = OneHotEncoder(),
-                 high_card_cat_transformer: Optional[Union[BaseEstimator, str]] = SimilarityEncoder(),
+                 high_card_cat_transformer: Optional[Union[BaseEstimator, str]] = GapEncoder(),
                  numerical_transformer: Optional[Union[BaseEstimator, str]] = None,
                  datetime_transformer: Optional[Union[BaseEstimator, str]] = None,
                  auto_cast: bool = False,
diff --git a/dirty_cat/utils.py b/dirty_cat/utils.py
index c9808b1..63d8ca2 100644
--- a/dirty_cat/utils.py
+++ b/dirty_cat/utils.py
@@ -1,5 +1,7 @@
 import collections
 
+import numpy as np
+
 
 class LRUDict:
     """ dict with limited capacity
@@ -27,3 +29,15 @@ class LRUDict:
 
     def __contains__(self, key):
         return key in self.cache
+
+
+def check_input(X):
+    # Check input data shape
+    X = np.asarray(X)
+    if X.ndim != 2:
+        raise ValueError(
+            'Expected 2D array. Reshape your data either using'
+            'array.reshape(-1, 1) if your data has a single feature or'
+            'array.reshape(1, -1) if it contains a single sample.'
+        )
+    return X
diff --git a/examples/03_automatic_preprocessing_with_the_supervectorizer.py b/examples/03_automatic_preprocessing_with_the_supervectorizer.py
index cca9d0c..69ed18a 100644
--- a/examples/03_automatic_preprocessing_with_the_supervectorizer.py
+++ b/examples/03_automatic_preprocessing_with_the_supervectorizer.py
@@ -32,6 +32,7 @@ We demonstrate it on the `employee salaries` dataset.
 # ------------------
 # Let's fetch the dataset, and load X (the employees' features) and y
 # (the salary to predict):
+from dirty_cat.gap_encoder import GapEncoder
 from dirty_cat.datasets import fetch_employee_salaries
 employee_salaries = fetch_employee_salaries()
 print(employee_salaries['DESCR'])
@@ -109,7 +110,11 @@ print(f'std={np.std(scores)}')
 #
 # Let us perform the same workflow, but without the `Pipeline`, so we can
 # analyze its mechanisms along the way.
-sup_vec = SuperVectorizer(auto_cast=True)
+sup_vec = SuperVectorizer(
+    auto_cast=True,
+    high_card_str_transformer=GapEncoder(n_components=50),
+    high_card_cat_transformer=GapEncoder(n_components=50)
+)
 
 ##############################################################################
 # We split the data between train and test, and transform them:
@@ -156,7 +161,7 @@ feature_names[:8]
 # which was classified as a high cardinality string variable.
 # (default values, see |SV|'s docstring).
 #
-# In total, we have 1212 encoded columns.
+# In total, we have 256 encoded columns.
 len(feature_names)
 
 
@@ -205,5 +210,5 @@ plt.show()
 
 ###############################################################################
 # We can deduce from this data that the three factors that define the
-# most the salary are: being a manager, being hired for a long time, and
-# have a permanent, full-time job :).
+# most the salary are: being hired for a long time, being a manager, and
+# having a permanent, full-time job :).
diff --git a/examples/04_feature_interpretation_gap_encoder.py b/examples/04_feature_interpretation_gap_encoder.py
index 2fddfc8..ef84f03 100644
--- a/examples/04_feature_interpretation_gap_encoder.py
+++ b/examples/04_feature_interpretation_gap_encoder.py
@@ -28,7 +28,7 @@ df = employee_salaries['data']
 ################################################################################
 # Now, we retrieve the dirty column to encode:
 dirty_column = 'employee_position_title'
-X_dirty = df[dirty_column]
+X_dirty = df[[dirty_column]]
 print(X_dirty.head(), end='\n\n')
 print(f'Number of dirty entries = {len(X_dirty)}')
 
@@ -77,7 +77,7 @@ plt.imshow(encoded_labels)
 plt.xlabel('Latent topics', size=12)
 plt.xticks(range(0, 10), labels=topic_labels, rotation=50, ha='right')
 plt.ylabel('Data entries', size=12)
-plt.yticks(range(0, 20), labels=X_dirty[:20])
+plt.yticks(range(0, 20), labels=X_dirty[:20].to_numpy().flatten())
 plt.colorbar().set_label(label='Topic activations', size=12)
 plt.tight_layout()
 plt.show()


Test patch:
diff --git a/dirty_cat/test/test_gap_encoder.py b/dirty_cat/test/test_gap_encoder.py
index 135052d..d41678d 100644
--- a/dirty_cat/test/test_gap_encoder.py
+++ b/dirty_cat/test/test_gap_encoder.py
@@ -1,6 +1,8 @@
 import time
 import numpy as np
+from numpy.random import random
 import pytest
+import pandas as pd
 
 from sklearn.datasets import fetch_20newsgroups
 from dirty_cat import GapEncoder
@@ -11,8 +13,8 @@ from dirty_cat import GapEncoder
     (True, 'k-means', 'char_wb', True)
 ])
 def test_gap_encoder(hashing, init, analyzer, add_words, n_samples=70):
-    X_txt = fetch_20newsgroups(subset='train')['data']
-    X = X_txt[:n_samples]
+    X_txt = fetch_20newsgroups(subset='train')['data'][:n_samples]
+    X = np.array([X_txt, X_txt]).T
     n_components = 10
     # Test output shape
     encoder = GapEncoder(
@@ -21,13 +23,13 @@ def test_gap_encoder(hashing, init, analyzer, add_words, n_samples=70):
         random_state=42, rescale_W=True)
     encoder.fit(X)
     y = encoder.transform(X)
-    assert y.shape == (n_samples, n_components), str(y.shape)
-    assert len(set(y[0])) == n_components
+    assert y.shape == (n_samples, n_components * X.shape[1]), str(y.shape)
 
     # Test L1-norm of topics W.
-    l1_norm_W = np.abs(encoder.W_).sum(axis=1)
-    np.testing.assert_array_almost_equal(
-        l1_norm_W, np.ones(n_components))
+    for col_enc in encoder.fitted_models_:
+        l1_norm_W = np.abs(col_enc.W_).sum(axis=1)
+        np.testing.assert_array_almost_equal(
+            l1_norm_W, np.ones(n_components))
 
     # Test same seed return the same output
     encoder = GapEncoder(
@@ -41,22 +43,33 @@ def test_gap_encoder(hashing, init, analyzer, add_words, n_samples=70):
 
 
 def test_input_type():
-    # Numpy array
-    X = np.array(['alice', 'bob'])
+    # Numpy array with one column
+    X = np.array([['alice'], ['bob']])
     enc = GapEncoder(n_components=2, random_state=42)
     X_enc_array = enc.fit_transform(X)
     # List
-    X = ['alice', 'bob']
+    X2 = [['alice'], ['bob']]
     enc = GapEncoder(n_components=2, random_state=42)
-    X_enc_list = enc.fit_transform(X)
+    X_enc_list = enc.fit_transform(X2)
     # Check if the encoded vectors are the same
     np.testing.assert_array_equal(X_enc_array, X_enc_list)
+    
+    # Numpy array with two columns
+    X = np.array([['alice', 'charlie'], ['bob', 'delta']])
+    enc = GapEncoder(n_components=2, random_state=42)
+    X_enc_array = enc.fit_transform(X)
+    # Pandas dataframe with two columns
+    df = pd.DataFrame(X)
+    enc = GapEncoder(n_components=2, random_state=42)
+    X_enc_df = enc.fit_transform(df)
+    # Check if the encoded vectors are the same
+    np.testing.assert_array_equal(X_enc_array, X_enc_df)
     return
 
 
 def test_partial_fit(n_samples=70):
-    X_txt = fetch_20newsgroups(subset='train')['data']
-    X = X_txt[:n_samples]
+    X_txt = fetch_20newsgroups(subset='train')['data'][:n_samples]
+    X = np.array([X_txt, X_txt]).T
     # Gap encoder with fit on one batch
     enc = GapEncoder(random_state=42, batch_size=n_samples, max_iter=1)
     X_enc = enc.fit_transform(X)
@@ -70,13 +83,18 @@ def test_partial_fit(n_samples=70):
 
 
 def test_get_feature_names(n_samples=70):
-    X_txt = fetch_20newsgroups(subset='train')['data']
-    X = X_txt[:n_samples]
-    enc = GapEncoder()
+    X_txt = fetch_20newsgroups(subset='train')['data'][:n_samples]
+    X = np.array([X_txt, X_txt]).T
+    enc = GapEncoder(random_state=42)
     enc.fit(X)
     topic_labels = enc.get_feature_names()
     # Check number of labels
-    assert len(topic_labels) == enc.n_components
+    assert len(topic_labels) == enc.n_components * X.shape[1]
+    # Test different parameters for col_names
+    topic_labels_2 = enc.get_feature_names(col_names='auto')
+    assert topic_labels_2[0] == 'col0: ' + topic_labels[0]
+    topic_labels_3 = enc.get_feature_names(col_names=['abc', 'def'])
+    assert topic_labels_3[0] == 'abc: ' + topic_labels[0]
     return
 
 def test_overflow_error():
@@ -88,13 +106,26 @@ def test_overflow_error():
     enc.fit(X)
     return
 
+def test_score(n_samples=70):
+    X_txt = fetch_20newsgroups(subset='train')['data'][:n_samples]
+    X1 = np.array(X_txt)[:, None]
+    X2 = np.hstack([X1, X1])
+    enc = GapEncoder(random_state=42)
+    enc.fit(X1)
+    score_X1 = enc.score(X1)
+    enc.fit(X2)
+    score_X2 = enc.score(X2)
+    # Check that two identical columns give the same score
+    assert score_X1 * 2 == score_X2
+    return
+
 def profile_encoder(Encoder, init):
     # not an unit test
     
     from dirty_cat import datasets
     employee_salaries = datasets.fetch_employee_salaries()
     data = employee_salaries['data']
-    X = data['employee_position_title'].tolist()
+    X = np.array(data['employee_position_title'])[:, None]
     t0 = time.time()
     encoder = Encoder(n_components=50, init=init)
     encoder.fit(X)
@@ -120,6 +151,9 @@ if __name__ == '__main__':
     print('start test_overflow_error')
     test_overflow_error()
     print('test_overflow_error passed')
+    print('start test_score')
+    test_score()
+    print('test_score passed')
     
     for _ in range(3):
         print('time profile_encoder(GapEncoder, init="k-means++")')
diff --git a/dirty_cat/test/test_super_vectorizer.py b/dirty_cat/test/test_super_vectorizer.py
index b70c0d1..2f34d8e 100644
--- a/dirty_cat/test/test_super_vectorizer.py
+++ b/dirty_cat/test/test_super_vectorizer.py
@@ -7,6 +7,7 @@ from sklearn.preprocessing import StandardScaler
 from distutils.version import LooseVersion
 
 from dirty_cat import SuperVectorizer
+from dirty_cat import GapEncoder
 
 
 def check_same_transformers(expected_transformers: dict, actual_transformers: list):
@@ -32,6 +33,9 @@ def test_super_vectorizer():
     # Test with low cardinality and a StandardScaler for the numeric columns
     vectorizer_base = SuperVectorizer(
         cardinality_threshold=3,
+        # we must have n_samples = 5 >= n_components
+        high_card_str_transformer=GapEncoder(n_components=2),
+        high_card_cat_transformer=GapEncoder(n_components=2),
         numerical_transformer=StandardScaler(),
     )
     # Warning: order-dependant
@@ -76,6 +80,9 @@ def test_super_vectorizer():
     vectorizer_cast = SuperVectorizer(
         cardinality_threshold=3,
         auto_cast=True,
+        # we must have n_samples = 5 >= n_components
+        high_card_str_transformer=GapEncoder(n_components=2),
+        high_card_cat_transformer=GapEncoder(n_components=2),
         numerical_transformer=StandardScaler(),
     )
     X_str = X.astype('object')