Repository: dirty-cat/dirty_cat
Pull request #463 (2023-01-16 16:27:18)

Use `check_is_fitted` for all dirty_cat transformers.
This gives an intelligible error if someone calls `transform`. Solves #462 


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 8300c48..fad8b66 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,6 +2,9 @@
 
 .. currentmodule:: dirty_cat
 
+Release 0.5.0
+==============
+
 Release 0.4.0 (upcoming)
 =========================
 
@@ -11,6 +14,9 @@ Major changes
 * New experimental feature: joining tables using :func:`fuzzy_join` by approximate key matching. Matches are based
   on string similarities and the nearest neighbors matches are found for each category.
 :pr:`291` by :user:`Jovan Stojanovic <jovan-stojanovic>` and :user:`Leo Grinsztajn <LeoGrin>`
+* New experimental feature: :class:`FeatureAugmenter`, a transformer
+  that augments with :func:`fuzzy_join` the number of features in a main table
+  by using information from auxilliary tables. :pr:`409` by :user:`Jovan Stojanovic <jovan-stojanovic>`
 * **datasets.fetching**: contains a new function :func:`fetch_world_bank_indicator`
   that can be used to download any indicator from the World Bank Open Data platform.
   It only needs the indicator ID that can be found on the website. :pr:`291` by :user:`Jovan Stojanovic <jovan-stojanovic>`
@@ -18,6 +24,9 @@ Major changes
   starting with an underscore shouldn't be imported in your code. :pr:`331` by :user:`Lilian Boulard <LilianBoulard>`
 * The :class:`MinHashEncoder` now supports a `n_jobs` parameter to parallelize
   the hashes computation. :pr:`267` by :user:`Leo Grinsztajn <LeoGrin>` and :user:`Lilian Boulard <LilianBoulard>`.
+* New experimental feature: deduplicating misspelled categories using :func:`deduplicate` by clustering string distances.
+  This function works best when there are significantly more duplicates than underlying categories.
+  :pr:`339` by :user:`Moritz Boos <mjboos>`.
 
 Minor changes
 -------------
diff --git a/dirty_cat/__init__.py b/dirty_cat/__init__.py
index dc13251..40f30b8 100644
--- a/dirty_cat/__init__.py
+++ b/dirty_cat/__init__.py
@@ -15,6 +15,8 @@ except ModuleNotFoundError:
     )
 
 from ._datetime_encoder import DatetimeEncoder
+from ._deduplicate import compute_ngram_distance, deduplicate
+from ._feature_augmenter import FeatureAugmenter
 from ._fuzzy_join import fuzzy_join
 from ._gap_encoder import GapEncoder
 from ._minhash_encoder import MinHashEncoder
@@ -27,11 +29,14 @@ with open(_Path(__file__).parent / "VERSION.txt") as _fh:
 
 
 __all__ = [
-    "SimilarityEncoder",
-    "TargetEncoder",
-    "MinHashEncoder",
-    "GapEncoder",
     "DatetimeEncoder",
-    "SuperVectorizer",
+    "FeatureAugmenter",
     "fuzzy_join",
+    "GapEncoder",
+    "MinHashEncoder",
+    "SimilarityEncoder",
+    "SuperVectorizer",
+    "TargetEncoder",
+    "deduplicate",
+    "compute_ngram_distance",
 ]
diff --git a/dirty_cat/_deduplicate.py b/dirty_cat/_deduplicate.py
new file mode 100644
index 0000000..fda228f
--- /dev/null
+++ b/dirty_cat/_deduplicate.py
@@ -0,0 +1,182 @@
+"""
+Implements deduplication based on clustering string distance matrices.
+This works best if there is a number of underlying categories that
+sometimes appear in the data with small variations and/or misspellings.
+"""
+from typing import List, Literal, Optional, Sequence, Tuple, Union
+
+import numpy as np
+import pandas as pd
+from scipy.cluster.hierarchy import fcluster, linkage
+from scipy.spatial.distance import pdist, squareform
+from sklearn.feature_extraction.text import TfidfVectorizer
+from sklearn.metrics import silhouette_score
+
+
+def compute_ngram_distance(
+    unique_words: Union[Sequence[str], np.ndarray],
+    ngram_range: Tuple[int, int] = (2, 4),
+    analyzer: str = "char_wb",
+) -> np.ndarray:
+    """Compute the condensed pair-wise n-gram distance between `unique_words`.
+
+    Parameters
+    ----------
+    unique_words : Union[Sequence[str], np.ndarray]
+        Sequence or array of unique words from the original data.
+    ngram_range : Tuple[int, int], optional, default=(2,4)
+        The n-gram range to compute the distance in.
+    analyzer : str, optional, default=`char_wb`
+        Analyzer to extract n-grams.
+
+    Returns
+    -------
+    np.ndarray
+        An n-times-(n-1)/2 array of n-gram tf-idf distances between `unique_words`.
+
+    Notes
+    -----
+    Extracts n-grams of all elements in `unique_words`, calculates the
+    term frequency-inverse document frequency (tf-idf) for each n-gram, then
+    computes the pair-wise euclidean distance between elements based on their n-gram
+    tf-idf representation.
+    """
+    encoded = TfidfVectorizer(ngram_range=ngram_range, analyzer=analyzer).fit_transform(
+        unique_words
+    )
+
+    distance_mat = pdist(encoded.todense(), metric="euclidean")
+    return distance_mat
+
+
+def _guess_clusters(Z: np.ndarray, distance_mat: np.ndarray) -> int:
+    """Finds the number of clusters that maximize the silhouette score
+    when clustering `distance_mat`.
+
+    Parameters
+    ----------
+    Z : np.ndarray
+        hierarchical linkage matrix, specifies which clusters to merge.
+    distance_mat : np.ndarray
+        distance matrix either in square or condensed form.
+
+    Returns
+    -------
+    int
+        number of clusters that maximize the silhouette score.
+    """
+    max_clusters = distance_mat.shape[0]
+    n_clusters = np.arange(2, max_clusters)
+    # silhouette score needs a redundant distance matrix
+    redundant_dist = squareform(distance_mat)
+    silhouette_scores = []
+    for n_clust in n_clusters:
+        labels = fcluster(Z, n_clust, criterion="maxclust")
+        silhouette_avg = silhouette_score(redundant_dist, labels, metric="precomputed")
+        silhouette_scores.append(silhouette_avg)
+    return n_clusters[np.argmax(silhouette_scores)]
+
+
+def _create_spelling_correction(
+    unique_words: Union[Sequence[str], np.ndarray],
+    counts: Union[Sequence[int], np.ndarray],
+    clusters: Sequence[int],
+) -> pd.Series:
+    """Creates a pandas Series that map each cluster member to the most
+    frequent cluster member. The assumption is that the most common spelling
+    is the correct one.
+
+    Parameters
+    ----------
+    unique_words : Union[Sequence[str], np.ndarray]
+        A sequence or array of unique words in the original data.
+    counts : Union[Sequence[int], np.ndarray]
+        A sequence or array of counts of how often each unique word appears in
+        the original data.
+    clusters : Sequence[int]
+        A sequence of ints, indicating cluster membership of each unique word
+        in `count_series`.
+
+    Returns
+    -------
+    pd.Series
+        Series with unique (original) words as indices and (estimated)
+        corrected spelling of each word as values.
+    """
+    count_series = pd.Series(counts, index=unique_words)
+    original_spelling: List[str] = []
+    corrected_spelling: List[str] = []
+    for cluster in np.unique(clusters):
+        sorted_spellings = (
+            count_series.loc[clusters == cluster]
+            .sort_values(ascending=False)
+            .index.tolist()
+        )
+        original_spelling.extend(sorted_spellings)
+        # assumes spelling that occurs most frequently in cluster is correct
+        corrected_spelling.extend(sorted_spellings[:1] * len(sorted_spellings))
+    pd_spell_correct = pd.Series(corrected_spelling, index=original_spelling)
+    return pd_spell_correct
+
+
+def deduplicate(
+    data: Sequence[str],
+    n_clusters: Optional[int] = None,
+    ngram_range: Tuple[int, int] = (2, 4),
+    analyzer: Literal["word", "char", "char_wb"] = "char_wb",
+    method: Literal[
+        "single", "complete", "average", "centroid", "median", "ward"
+    ] = "average",
+) -> List[str]:
+    """Deduplicate data by hierarchically clustering similar strings.
+
+    Parameters
+    ----------
+    data : Sequence[str]
+        The data to be deduplicated.
+    n_clusters : Optional[int], optional, default=None
+        Number of clusters to use for hierarchical clustering, if None use the
+        number of clusters that lead to the lowest silhouette score.
+    ngram_range : Tuple[int, int], optional, default=(2, 4)
+        Range to use for computing n-gram distance.
+    analyzer : typing.Literal["word", "char", "char_wb"], optional, default=`char_wb`
+        Analyzer parameter for the CountVectorizer used for the string
+        similarities.
+        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.
+    method : str, optional, default=`average`
+        Linkage method parameter to use for merging clusters via scipy's
+        `linkage` method.
+        Options: {`single`, `complete`, `average`, `centroid`, `median`, `ward`},
+        describing different methods to calculate the distance between two clusters.
+        Option `average` calculates the distance between two clusters as the average
+        distance between data points in the first and second cluster.
+
+    Returns
+    -------
+    List[str]
+       The deduplicated data.
+
+    Notes
+    -----
+    Deduplication is done by first computing the n-gram distance between unique
+    categories in data, then performing hierarchical clustering on this distance
+    matrix, and choosing the most frequent element in each cluster as the
+    'correct' spelling. This method works best if the true number of
+    categories is significantly smaller than the number of observed spellings.
+    """
+    unique_words, counts = np.unique(data, return_counts=True)
+    distance_mat = compute_ngram_distance(
+        unique_words, ngram_range=ngram_range, analyzer=analyzer
+    )
+
+    Z = linkage(distance_mat, method=method, optimal_ordering=True)
+    if n_clusters is None:
+        n_clusters = _guess_clusters(Z, distance_mat)
+    clusters = fcluster(Z, n_clusters, criterion="maxclust")
+
+    translation_table = _create_spelling_correction(unique_words, counts, clusters)
+    unrolled_corrections = translation_table[data]
+    return unrolled_corrections
diff --git a/dirty_cat/_feature_augmenter.py b/dirty_cat/_feature_augmenter.py
new file mode 100644
index 0000000..7dcf36d
--- /dev/null
+++ b/dirty_cat/_feature_augmenter.py
@@ -0,0 +1,197 @@
+"""
+Transformer that allows multiple fuzzy joins to be performed on a table.
+The principle is as follows:
+  1. The main table and the key column name are provided at initialisation.
+  2. The auxilliary tables are provided for fitting, and will be joined sequentially
+  when the transform is called.
+It is advised to use hyper-parameter tuning tools such as scikit-learn's
+GridSearchCV to determine the best `match_score` parameter, as this can
+significantly improve your results.
+(see example 'Fuzzy joining dirty tables with the FeatureAugmenter' for an illustration)
+For more information on how the join is performed, see fuzzy_join's documentation.
+"""
+
+from typing import List, Literal, Tuple
+
+import pandas as pd
+from sklearn.base import BaseEstimator, TransformerMixin
+
+from dirty_cat._fuzzy_join import fuzzy_join
+
+
+class FeatureAugmenter(BaseEstimator, TransformerMixin):
+    """Transformer augmenting the number of features in a table by joining multiple tables.
+
+    Given a list of tables and key column names,
+    fuzzy join them to the main table.
+
+    Parameters
+    ----------
+    tables : list of 2-tuples of (:class:`~pandas.DataFrame`, str)
+        List of (table, column name) tuples
+        specyfying the transformer objects to be applied.
+        table: str
+            Name of the table to be joined.
+        column name: str,
+            Name of table column to join on.
+    main_key : str
+        The key column name in the main table on which
+        the join will be performed.
+    match_score : float, default=0
+        Distance score between the closest matches that will be accepted.
+        In a [0, 1] interval. 1 means that only a perfect match will be
+        accepted, and zero means that the closest match will be accepted,
+        no matter how distant.
+    analyzer : typing.Literal["word", "char", "char_wb"], default=`char_wb`
+        Analyzer parameter for the CountVectorizer used for the string
+        similarities.
+        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.
+    ngram_range : tuple (min_n, max_n), default=(2, 4)
+        The lower and upper boundary of the range of n-values for different
+        n-grams used in the string similarity. All values of n such
+        that min_n <= n <= max_n will be used.
+
+    Examples
+    --------
+    >>> X = pd.DataFrame(['France', 'Germany', 'Italy'], columns=['Country'])
+    >>> X
+    Country
+    0   France
+    1  Germany
+    2    Italy
+
+    >>> aux_table_1 = pd.DataFrame([['Germany', 84_000_000], ['France', 68_000_000], ['Italy', 59_000_000]], columns=['Country', 'Population']) # noqa
+    >>> aux_table_1
+       Country  Population
+    0  Germany    84000000
+    1   France    68000000
+    2    Italy    59000000
+
+    >>> aux_table_2 = pd.DataFrame([['French Republic', 2937], ['Italy', 2099], ['Germany', 4223], ['UK', 3186]], columns=['Country name', 'GDP (billion)']) # noqa
+    >>> aux_table_2
+        Country name  GDP (billion)
+    0   French Republic      2937
+    1        Italy           2099
+    2      Germany           4223
+    3           UK           3186
+
+    >>> aux_table_3 = pd.DataFrame([['France', 'Paris'], ['Italia', 'Rome'], ['Germany', 'Berlin']], columns=['Countries', 'Capital']) # noqa
+    >>> aux_table_3
+      Countries Capital
+    0    France   Paris
+    1     Italia   Rome
+    2   Germany  Berlin
+
+    >>> aux_tables = [(aux_table_1, "Country"), (aux_table_2, "Country name"), (aux_table_3, "Countries")] # noqa
+
+    >>> fa = FeatureAugmenter(tables=aux_tables, main_key='Country')
+
+    >>> augmented_table = fa.fit_transform(X)
+    >>> augmented_table
+        Country Country_aux  Population Country name  GDP (billion) Countries Capital
+    0   France      France    68000000  French Republic       2937    France   Paris
+    1  Germany     Germany    84000000      Germany           4223   Germany  Berlin
+    2    Italy       Italy    59000000        Italy           2099    Italia    Rome
+    """
+
+    def __init__(
+        self,
+        tables: List[Tuple[pd.DataFrame, str]],
+        main_key: str,
+        match_score: float = 0.0,
+        analyzer: Literal["word", "char", "char_wb"] = "char_wb",
+        ngram_range: Tuple[int, int] = (2, 4),
+    ):
+        self.tables = tables
+        self.main_key = main_key
+        self.match_score = match_score
+        self.analyzer = analyzer
+        self.ngram_range = ngram_range
+
+    def fit(self, X, y=None) -> "FeatureAugmenter":
+        """Fit the Feature Augmenter to the main table.
+
+        In practice, just checks if the key columns in X,
+        the main table, and in the auxilliary tables exist.
+
+        Parameters
+        ----------
+        X : DataFrame, shape [n_samples, n_features]
+            The main table, to be joined to the
+            auxilliary ones.
+        y : array-like of shape (n_samples,...), default=None
+            Targets for supervised learning.
+
+        Returns
+        -------
+        FeatureAugmenter
+            Fitted FeatureAugmenter instance.
+        """
+
+        if self.main_key not in X.columns:
+            raise ValueError(
+                f"Got main_key={self.main_key!r}, but column not in {list(X.columns)}."
+            )
+
+        for pairs in self.tables:
+            if pairs[1] not in pairs[0].columns:
+                raise ValueError(
+                    f"Got column key {pairs[1]!r}, "
+                    f"but column not in {pairs[0].columns}."
+                )
+        return self
+
+    def transform(self, X, y=None) -> pd.DataFrame:
+        """Transform X using the specified encoding scheme.
+
+        Parameters
+        ----------
+        X : DataFrame, shape [n_samples, n_features]
+            The main table, to be joined to the
+            auxilliary ones.
+        y : array-like of shape (n_samples,...), default=None
+            Targets for supervised learning.
+
+        Returns
+        -------
+        DataFrame
+            The final joined table.
+        """
+
+        for pairs in self.tables:
+            # TODO: Add an option to fuzzy_join on multiple columns at once
+            # (will be if len(inter_col)!=0)
+            aux_table = pairs[0]
+            X = fuzzy_join(
+                X,
+                aux_table,
+                left_on=self.main_key,
+                right_on=pairs[1],
+                match_score=self.match_score,
+                analyzer=self.analyzer,
+                ngram_range=self.ngram_range,
+                suffixes=("", "_aux"),
+            )
+        return X
+
+    def fit_transform(self, X, y=None) -> pd.DataFrame:
+        """
+        Fit the FeatureAugmenter to X, then transforms it.
+
+        Parameters
+        ----------
+        X : DataFrame, shape [n_samples, n_features]
+            The main table, to be joined to the
+            auxilliary ones.
+        y : array-like of shape (n_samples,...), default=None
+            Targets for supervised learning.
+
+        Returns
+        -------
+        DataFrame
+            The final joined table.
+        """
+        return self.fit(X).transform(X)
diff --git a/dirty_cat/_fuzzy_join.py b/dirty_cat/_fuzzy_join.py
index 9a3313a..00b442d 100644
--- a/dirty_cat/_fuzzy_join.py
+++ b/dirty_cat/_fuzzy_join.py
@@ -51,8 +51,8 @@ def fuzzy_join(
         A table to merge.
     right : :class:`~pandas.DataFrame`
         A table used to merge with.
-    how : {"left", "right"}, optional, default=`left`
-        Type of merge to be performed. Note that unlike pandas' merge,
+    how: typing.Literal["left", "right"], default=`left`
+        Type of merge to be performed. Note that unlike :func:`~pandas.merge`,
         only "left" and "right" are supported so far, as the fuzzy-join comes
         with its own mechanism to resolve lack of correspondence between
         left and right tables.
@@ -86,9 +86,9 @@ def fuzzy_join(
         the nearest matched categories.
     match_score : float, default=0.0
         Distance score between the closest matches that will be accepted.
-        In a [0, 1] interval. Closer to 1 means the matches need to be very
-        close to be accepted, and closer to 0 that a bigger matching distance
-        is tolerated.
+        In a [0, 1] interval. 1 means that only a perfect match will be
+        accepted, and zero means that the closest match will be accepted,
+        no matter how distant.
     drop_unmatched : boolean, default=False
         Remove categories for which a match was not found in the two tables.
     sort : boolean, default=False
diff --git a/dirty_cat/_gap_encoder.py b/dirty_cat/_gap_encoder.py
index a0dc6d4..5c5298b 100644
--- a/dirty_cat/_gap_encoder.py
+++ b/dirty_cat/_gap_encoder.py
@@ -30,6 +30,7 @@ from sklearn.neighbors import NearestNeighbors
 from sklearn.utils import check_random_state, gen_batches
 from sklearn.utils.extmath import row_norms, safe_sparse_dot
 from sklearn.utils.fixes import _object_dtype_isnan
+from sklearn.utils.validation import check_is_fitted
 
 from ._utils import check_input, parse_version
 
@@ -512,6 +513,7 @@ class GapEncoderColumn(BaseEstimator, TransformerMixin):
         H : 2-d array, shape (n_samples, n_topics)
             Transformed input.
         """
+        check_is_fitted(self, "H_dict_")
         # Check if first item has str or np.str_ type
         assert isinstance(X[0], str), "Input data is not string. "
         unq_X = np.unique(X)
@@ -810,7 +812,7 @@ class GapEncoder(BaseEstimator, TransformerMixin):
         H : 2-d array, shape (n_samples, n_topics * n_features)
             Transformed input.
         """
-
+        check_is_fitted(self, "fitted_models_")
         # Check input data shape
         X = check_input(X)
         X = self._handle_missing(X)
diff --git a/dirty_cat/_minhash_encoder.py b/dirty_cat/_minhash_encoder.py
index f45d4b8..4a56562 100644
--- a/dirty_cat/_minhash_encoder.py
+++ b/dirty_cat/_minhash_encoder.py
@@ -22,6 +22,7 @@ import numpy as np
 from joblib import Parallel, delayed, effective_n_jobs
 from sklearn.base import BaseEstimator, TransformerMixin
 from sklearn.utils import gen_even_slices, murmurhash3_32
+from sklearn.utils.validation import check_is_fitted
 
 from ._fast_hash import ngram_min_hash
 from ._string_distances import get_unique_ngrams
@@ -261,6 +262,7 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
         :obj:`~numpy.ndarray` of shape (n_samples, n_components)
             Transformed input.
         """
+        check_is_fitted(self, "hash_dict_")
         X = check_input(X)
         if self.minmax_hash:
             if self.n_components % 2 != 0:
diff --git a/dirty_cat/_similarity_encoder.py b/dirty_cat/_similarity_encoder.py
index 6367981..b6d37a9 100644
--- a/dirty_cat/_similarity_encoder.py
+++ b/dirty_cat/_similarity_encoder.py
@@ -27,6 +27,7 @@ from sklearn.neighbors import NearestNeighbors
 from sklearn.preprocessing import OneHotEncoder
 from sklearn.utils import check_random_state
 from sklearn.utils.fixes import _object_dtype_isnan
+from sklearn.utils.validation import check_is_fitted
 
 from ._string_distances import get_ngram_count, preprocess
 from ._utils import parse_version
@@ -521,7 +522,7 @@ class SimilarityEncoder(OneHotEncoder):
         X_new : 2-d array, shape [n_samples, n_features_new]
             Transformed input.
         """
-
+        check_is_fitted(self, "categories_")
         if hasattr(X, "iloc") and X.isna().values.any():
             if self.handle_missing == "error":
                 raise ValueError(
diff --git a/dirty_cat/_super_vectorizer.py b/dirty_cat/_super_vectorizer.py
index 97d7d95..107e1e7 100644
--- a/dirty_cat/_super_vectorizer.py
+++ b/dirty_cat/_super_vectorizer.py
@@ -15,6 +15,7 @@ from sklearn import __version__ as sklearn_version
 from sklearn.base import TransformerMixin, clone
 from sklearn.compose import ColumnTransformer
 from sklearn.preprocessing import OneHotEncoder
+from sklearn.utils.validation import check_is_fitted
 
 from dirty_cat import DatetimeEncoder, GapEncoder
 from dirty_cat._utils import parse_version
@@ -607,6 +608,7 @@ class SuperVectorizer(ColumnTransformer):
             any result is a sparse matrix, everything will be converted to
             sparse matrices.
         """
+        check_is_fitted(self, attributes=["columns_"])
         if X.shape[1] != len(self.columns_):
             raise ValueError(
                 "Passed array does not match column count of "
diff --git a/dirty_cat/datasets/__init__.py b/dirty_cat/datasets/__init__.py
index ff8ccc4..0dfc30f 100644
--- a/dirty_cat/datasets/__init__.py
+++ b/dirty_cat/datasets/__init__.py
@@ -12,6 +12,12 @@ from ._fetching import (
     get_data_dir,
 )
 
+
+from ._generating import (
+    make_deduplication_data,
+)
+
+
 __all__ = [
     "DatasetAll",
     "DatasetInfoOnly",
@@ -24,4 +30,5 @@ __all__ = [
     "fetch_traffic_violations",
     "fetch_world_bank_indicator",
     "get_data_dir",
+    "make_deduplication_data",
 ]
diff --git a/dirty_cat/datasets/_fetching.py b/dirty_cat/datasets/_fetching.py
index eeba1a8..b0eeb77 100644
--- a/dirty_cat/datasets/_fetching.py
+++ b/dirty_cat/datasets/_fetching.py
@@ -818,3 +818,5 @@ def fetch_world_bank_indicator(
         load_dataframe=load_dataframe,
         data_directory=directory,
     )
+
+
diff --git a/dirty_cat/datasets/_generating.py b/dirty_cat/datasets/_generating.py
new file mode 100644
index 0000000..d379a33
--- /dev/null
+++ b/dirty_cat/datasets/_generating.py
@@ -0,0 +1,60 @@
+"""
+Functions that generate example data.
+
+"""
+from __future__ import annotations
+
+from typing import List, Optional, Union
+import string
+
+import numpy as np
+
+from sklearn.utils import check_random_state
+
+def make_deduplication_data(
+    examples: List[str],
+    entries_per_example: List[int],
+    prob_mistake_per_letter: float,
+    random_state: Optional[Union[int, np.random.RandomState]] = None,
+) -> List[str]:
+    """Duplicates examples with spelling mistakes.
+    Characters are misspelled with probability `prob_mistake_per_letter`.
+
+    Parameters
+    ----------
+    examples : List[str]
+        examples to duplicate
+    entries_per_example : List[int]
+        number of duplications per example
+    prob_mistake_per_letter : float in [0, 1]
+        probability of misspelling a character in duplications
+    random_state : int, RandomState instance or None, default=None
+        Determines random number generation for dataset noise. Pass an int
+        for reproducible output across multiple function calls.
+
+    Returns
+    -------
+    List[str]
+        list of duplicated examples with spelling mistakes
+    """
+    rng = check_random_state(random_state)
+
+    data = []
+    for example, n_ex in zip(examples, entries_per_example):
+        len_ex = len(example)
+        # generate a 2D array of chars of size (n_ex, len_ex)
+        str_as_list = np.array([list(example)] * n_ex)
+        # randomly choose which characters are misspelled
+        idxes = np.where(
+            rng.random(len(example[0]) * n_ex) < prob_mistake_per_letter
+        )[0]
+        # and randomly pick with which character to replace
+        replacements = [
+            string.ascii_lowercase[i]
+            for i in np.random.choice(np.arange(26), len(idxes)).astype(int)
+        ]
+        # introduce spelling mistakes at right examples and char locations per example
+        str_as_list[idxes // len_ex, idxes % len_ex] = replacements
+        # go back to 1d array of strings
+        data.append(np.ascontiguousarray(str_as_list).view(f"U{len_ex}").ravel())
+    return np.concatenate(data).tolist()
diff --git a/doc/index.rst b/doc/index.rst
index 282059b..de61a71 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -1,7 +1,7 @@
 
-===============================================
-dirty_cat: machine learning on dirty categories
-===============================================
+=================================================
+dirty_cat: machine learning with dirty categories
+=================================================
 
 .. toctree::
    :maxdepth: 2
@@ -10,10 +10,12 @@ dirty_cat: machine learning on dirty categories
 
 .. container:: larger-container
 
-    `dirty_cat` facilitates machine-learning on non-curated categories:
-    **robust to morphological variants**, such as typos.
-    See `the first example notebook <https://dirty-cat.github.io/stable/auto_examples/01_dirty_categories.html>`_
-    for a more in-depth look into the problem and the library's capabilities.
+    `dirty_cat` facilitates machine-learning with non-curated categories:
+    **robust to morphological variants**, such as typos. See
+    :ref:`examples <usage_examples>`, such as `the first one
+    <https://dirty-cat.github.io/stable/auto_examples/01_dirty_categories.html>`_,
+    for an introduction to problems of dirty categories or misspelled
+    entities.
 
 |
 
@@ -61,7 +63,18 @@ pipeline you can tweak.
 
     :Installing: ``$ pip install --user --upgrade dirty_cat``
 
+.. _usage_examples:
 
+Usage examples
+==============
+
+.. container:: larger-container
+
+  .. include:: auto_examples/index.rst
+    :start-line: 5
+    :end-before: .. rst-class:: sphx-glr-signature
+
+|
 
 .. raw:: html
 
@@ -78,17 +91,6 @@ pipeline you can tweak.
     </div>
 
 
-Usage examples
-==============
-
-.. container:: larger-container
-
-  .. include:: auto_examples/index.rst
-    :start-line: 5
-    :end-before: .. rst-class:: sphx-glr-signature
-
-|
-
 For a detailed description of the problem of encoding dirty categorical data,
 see `Similarity encoding for learning with dirty categorical variables
 <https://hal.inria.fr/hal-01806175>`_ [1]_ and `Encoding high-cardinality
@@ -141,8 +143,26 @@ Joining tables
 
    fuzzy_join
 
-Data download
--------------
+.. autosummary::
+   :toctree: generated/
+   :template: class.rst
+   :nosignatures:
+
+   FeatureAugmenter
+
+Deduplication: merging variants of the same entry
+---------------------------------------------------
+
+.. autosummary::
+   :toctree: generated/
+   :template: function.rst
+   :nosignatures:
+
+   deduplicate
+
+
+Data download and generation
+----------------------------
 
 .. autosummary::
    :toctree: generated/
@@ -158,6 +178,7 @@ Data download
    datasets.fetch_drug_directory
    datasets.fetch_world_bank_indicator
    datasets.get_data_dir
+   datasets.make_deduplication_data
 
 About
 =====
diff --git a/examples/05_joining_tables_with_FuzzyJoin.py b/examples/05_fuzzy_joining_and_FeatureAugmenter.py
old mode 100755
new mode 100644
similarity index 74%
rename from examples/05_joining_tables_with_FuzzyJoin.py
rename to examples/05_fuzzy_joining_and_FeatureAugmenter.py
index 6f1d561..b222e28
--- a/examples/05_joining_tables_with_FuzzyJoin.py
+++ b/examples/05_fuzzy_joining_and_FeatureAugmenter.py
@@ -1,6 +1,6 @@
 """
-Merging dirty tables: fuzzy join
-================================
+Fuzzy joining dirty tables and the FeatureAugmenter
+=====================================================
 
 Here we show how to combine data from different sources,
 with a vocabulary not well normalized.
@@ -8,16 +8,22 @@ with a vocabulary not well normalized.
 Joining is difficult: one entry on one side does not have
 an exact match on the other side.
 
-In this example, the |fj| function allows us to join
-tables without cleaning the data by taking into account the
-label variations.
+The |fj| function enables to join tables without cleaning the data by
+accounting for the label variations.
 
 To illustrate, we will join data from the `2022 World Happiness Report <https://worldhappiness.report/>`_.
 with tables provided in `the World Bank open data platform <https://data.worldbank.org/>`_
-in order to create a satisfying first prediction model.
+in order to create a first prediction model.
+
+Moreover, the |fa| is a scikit-learn Transformer that makes it easy to
+use such fuzzy joining multiple tables to bring in information in a
+machine-learning pipeline. In particular, it enables tuning parameters of
+|fj| to find the matches that maximize prediction accuracy.
 
 
 .. |fj| replace:: :func:`~dirty_cat.fuzzy_join`
+
+.. |fa| replace:: :func:`~dirty_cat.FeatureAugmenter`
 """
 
 ###############################################################################
@@ -110,6 +116,11 @@ gdppc.sort_values(by="Country Name").tail(7)
 # To join them with dirty_cat, we only need to do the following:
 from dirty_cat import fuzzy_join
 
+# We will ignore the warnings:
+import warnings
+
+warnings.filterwarnings("ignore")
+
 df1 = fuzzy_join(
     df,  # our table to join
     gdppc,  # the table to join with
@@ -269,10 +280,6 @@ plt.show()
 ###############################################################################
 # 3. Joining legal rights strength table
 # ......................................
-# .. topic:: Note:
-#
-#    Here, we use the `keep='left'` option to keep only the left key matching
-#    column, so as not to have unnecessary overlaping column with country names.
 #
 # And the table with a measure of legal rights strength in the country:
 df3 = fuzzy_join(
@@ -341,10 +348,7 @@ cv_results_t = cross_validate(hgdb, X, y, cv=cv, scoring="r2")
 
 cv_r2_t = cv_results_t["test_score"]
 
-print(
-    f"Mean R2 score with {len(X.columns) - 2} feature columns is"
-    f" {cv_r2_t.mean():.2f} +- {cv_r2_t.std():.2f}"
-)
+print(f"Mean R2 score is {cv_r2_t.mean():.2f} +- {cv_r2_t.std():.2f}")
 
 #################################################################
 # We have a satisfying first result: an R2 of 0.66!
@@ -358,3 +362,98 @@ print(
 #
 # Now up to you, try improving our model by adding information into it and
 # beating our result!
+
+#######################################################################
+# Using the |fa| to fuzzy join multiple tables
+# --------------------------------------------
+# A faster way to merge different tables from the World Bank
+# to `X` is to use the |fa|.
+#
+# The |fa| is a transformer that can easily chain joins of tables on
+# a main table.
+
+#######################################################################
+# Instantiating the transformer
+# ............................
+
+y = df["Happiness score"]
+#######################################################################
+# We gather the auxilliary tables into a
+# list of (tables, keys) for the `tables` parameter.
+# An instance of the transformer with the necessary information is:
+from dirty_cat import FeatureAugmenter
+
+fa = FeatureAugmenter(
+    tables=[
+        (gdppc, "Country Name"),
+        (life_exp, "Country Name"),
+        (legal_rights, "Country Name"),
+    ],
+    main_key="Country",
+)
+
+#################################################################
+# Fitting and transforming into the final table
+# ............................................
+# To get our final joined table we will fit and transform the main table (df)
+# with our create instance of the |fa|:
+df_final = fa.fit_transform(df)
+
+df_final.head(10)
+
+##########################################################################
+# And that's it! As previously, we now have a big table
+# ready for machine learning.
+# Let's create our machine learning pipeline:
+from sklearn.pipeline import make_pipeline
+from sklearn.compose import make_column_transformer
+
+# We include only the columns that will be pertinent for our regression:
+encoder = make_column_transformer(
+    (
+        "passthrough",
+        [
+            "GDP per capita (current US$)",
+            "Life expectancy at birth, total (years)",
+            "Strength of legal rights index (0=weak to 12=strong)",
+        ],
+    ),
+    remainder="drop",
+)
+
+pipeline = make_pipeline(fa, encoder, HistGradientBoostingRegressor())
+
+##########################################################################
+# And the best part is that we are now able to evaluate the paramaters of the |fj|.
+# For instance, the ``match_score`` was manually picked and can now be
+# introduced into a grid search:
+
+from sklearn.model_selection import GridSearchCV
+
+# We will test four possible values of match_score:
+params = {"featureaugmenter__match_score": [0.2, 0.3, 0.4, 0.5]}
+
+grid = GridSearchCV(pipeline, param_grid=params)
+grid.fit(df, y)
+
+print(grid.best_params_)
+##########################################################################
+# The grid searching gave us the best value of 0.5 for the parameter
+# ``match_score``. Let's use this value in our regression:
+#
+
+print(f"Mean R2 score with pipeline is {grid.score(df, y):.2f}")
+
+##########################################################################
+#
+# .. topic:: Note:
+#
+#    Here, ``grid.score()`` takes directly the best model
+#    (with ``match_score=0.5``) that was found during the grid search.
+#    Thus, it is equivalent to fixing the ``match_score`` to 0.5 and
+#    refitting the pipeline on the data.
+#
+#
+# Great, by evaluating the correct ``match_score`` we improved our
+# results significantly!
+#
diff --git a/examples/06_deduplication.py b/examples/06_deduplication.py
new file mode 100644
index 0000000..0c3fcf8
--- /dev/null
+++ b/examples/06_deduplication.py
@@ -0,0 +1,168 @@
+"""
+Deduplicating misspelled categories with deduplicate
+====================================================
+
+Real world datasets often come with slight misspellings in the category
+names, for instance if the category is manually input. Such misspellings
+break many data-analyses steps that require exact matching, such as a
+'GROUP BY'.
+
+Merging the multiple variants of the same category or entity is known as
+*deduplication*. It is performed by the :func:`~dirty_cat.deduplicate`
+function.
+
+Deduplication relies on *unsupervised learning*, to find structure in
+data without providing explicit labels/categories of the data a-priori.
+Specifically clustering of the distance between strings can be used to
+find clusters of strings that are similar to each other (e.g. differ only
+by a misspelling) and hence gives us an easy tool to flag potentially
+misspelled category names in an unsupervised manner.
+"""
+
+###############################################################################
+# An example dataset
+# -------------------
+#
+# Imagine the following example:
+# As a data scientist, our job is to analyze the data from a hospital ward.
+# We notice that most of the cases involve the prescription of one of three different medications:
+#  "Contrivan", "Genericon", or "Zipholan".
+# However, data entry is manual and - either because the prescribing doctor's handwriting
+# was hard to decipher, or due to mistakes during data input - there are multiple
+# spelling mistakes for these three medications.
+#
+# Let's generate some example data that demonstrate this.
+
+
+import numpy as np
+from dirty_cat.datasets import make_deduplication_data
+
+# our three medication names
+medications = ["Contrivan", "Genericon", "Zipholan"]
+entries_per_medications = [500, 100, 1500]
+
+# 5% probability of a typo per letter
+prob_mistake_per_letter = 0.05
+
+duplicated_names = make_deduplication_data(
+    medications, entries_per_medications, prob_mistake_per_letter,
+    random_state=42, # set seed for reproducibility
+)
+# we extract the unique medication names in the data & how often they appear
+unique_examples, counts = np.unique(duplicated_names, return_counts=True)
+# and build a series out of them
+import pandas as pd
+ex_series = pd.Series(counts, index=unique_examples)
+
+# This is our data:
+ex_series.head()
+
+###############################################################################
+# Visualize the data
+# ------------------
+
+import matplotlib.pyplot as plt
+
+ex_series.plot.barh(figsize=(10, 15))
+plt.xlabel("Medication name")
+plt.ylabel("Counts")
+
+###############################################################################
+# We can now see clearly the structure of the data: The three original medications
+# are the most common ones, however there are many spelling mistakes and hence
+# many slight variations of the names of the original medications.
+#
+# The idea is to use the fact that the string-distance of each misspelled medication
+# name will be closest to either the correctly or incorrectly spelled orginal
+# medication name - and therefore form clusters.
+
+###############################################################################
+# We can visualize the pair-wise distance between all medication names
+# --------------------------------------------------------------------
+#
+# Below we use a heatmap to visualize the pairwise-distance between medication names.
+# A darker color means that two medication names are closer together (i.e. more similar),
+# a lighter color means a larger distance. We can see that we are dealing with three
+# clusters - the original medication names and their misspellings that cluster around them.
+
+from dirty_cat import compute_ngram_distance
+from scipy.spatial.distance import squareform
+
+ngram_distances = compute_ngram_distance(unique_examples)
+square_distances = squareform(ngram_distances)
+
+import seaborn as sns
+
+fig, axes = plt.subplots(1, 1, figsize=(12, 12))
+sns.heatmap(
+    square_distances, yticklabels=ex_series.index, xticklabels=ex_series.index, ax=axes
+)
+
+###############################################################################
+# Deduplication: suggest corrections of misspelled names
+# -----------------------------------------------------
+#
+# The :func:`~dirty_cat.deduplicate` function uses clustering based on
+# string similarities to group duplicated names
+#
+# The number of clusters will need some adjustment depending on the data you have.
+# If no fixed number of clusters is given, `deduplicate` tries to set it automatically
+# via the `silhouette score <https://scikit-learn.org/stable/modules/clustering.html#silhouette-coefficient>`_.
+
+from dirty_cat import deduplicate
+
+deduplicated_data = deduplicate(duplicated_names)
+
+###############################################################################
+# We can visualize the distribution of categories in the deduplicated data:
+
+deduplicated_unique_examples, deduplicated_counts = np.unique(
+    deduplicated_data, return_counts=True
+)
+deduplicated_series = pd.Series(deduplicated_counts, index=deduplicated_unique_examples)
+
+deduplicated_series.plot.barh(figsize=(10, 15))
+plt.xlabel("Medication name")
+plt.ylabel("Counts")
+
+###############################################################################
+# In this example we can correct all spelling mistakes by using the ideal number
+# of clusters as determined by the silhouette score.
+#
+# However, often the translation/deduplication won't be perfect and will require some tweaks.
+# In this case, we can construct and update a translation table based on the data
+# returned by `deduplicate`.
+# It consists of the (potentially) misspelled category names as indices and the
+# (potentially) correct categories as values.
+
+# create a table that maps original -> corrected categories
+translation_table = pd.Series(deduplicated_data, index=duplicated_names)
+
+# remove duplicates in the original data
+translation_table = translation_table[~translation_table.index.duplicated(keep="first")]
+
+###############################################################################
+# Since the number of correct spellings will likely be much smaller than the
+# number of original categories, we can print the estimated cluster and their
+# most common exemplars (the guessed correct spelling):
+
+
+def print_corrections(spell_correct):
+    correct = np.unique(spell_correct.values)
+    for c in correct:
+        print(
+            f"Guessed correct spelling: {c!r} for "
+            f"{spell_correct[spell_correct==c].index.values}"
+        )
+
+
+print_corrections(translation_table)
+
+###############################################################################
+# In case we want to adapt the translation table post-hoc we can easily
+# modified it manually and apply it, for instance modifying the
+# correspondance for the last entry as such:
+
+translation_table.iloc[-1] = "Completely new category"
+new_deduplicated_names = translation_table[duplicated_names]
+assert (new_deduplicated_names == "Completely new category").sum() > 0


Test patch:
diff --git a/dirty_cat/datasets/tests/test_fetching.py b/dirty_cat/datasets/tests/test_fetching.py
index 2c8605d..561dcaa 100644
--- a/dirty_cat/datasets/tests/test_fetching.py
+++ b/dirty_cat/datasets/tests/test_fetching.py
@@ -453,3 +453,5 @@ def test_fetch_world_bank_indicator():
 
     finally:
         shutil.rmtree(path=str(test_data_dir), ignore_errors=True)
+
+
diff --git a/dirty_cat/datasets/tests/test_generating.py b/dirty_cat/datasets/tests/test_generating.py
new file mode 100644
index 0000000..d1d399f
--- /dev/null
+++ b/dirty_cat/datasets/tests/test_generating.py
@@ -0,0 +1,29 @@
+"""
+Tests generating.py (synthetic dataset generation).
+"""
+
+import numpy as np
+
+from dirty_cat.datasets._generating import make_deduplication_data
+
+
+
+def test_make_deduplication_data():
+
+    np.random.seed(123)
+    assert make_deduplication_data(["abc", "cba", "test1"], [3, 2, 1], 0.3) == [
+        "agr",
+        "abc",
+        "abc",
+        "cba",
+        "cba",
+        "test1",
+    ]
+    assert make_deduplication_data(["abc", "cba", "test1"], [1, 2, 3], 0.8) == [
+        "pbc",
+        "pza",
+        "cba",
+        "erxt1",
+        "test1",
+        "test1",
+    ]
diff --git a/dirty_cat/tests/test_datetime_encoder.py b/dirty_cat/tests/test_datetime_encoder.py
index 22705ea..124ae9d 100644
--- a/dirty_cat/tests/test_datetime_encoder.py
+++ b/dirty_cat/tests/test_datetime_encoder.py
@@ -1,5 +1,7 @@
+import pytest
 import numpy as np
 import pandas as pd
+from sklearn.exceptions import NotFittedError
 
 from dirty_cat._datetime_encoder import DatetimeEncoder
 
@@ -340,3 +342,14 @@ def test_transform():
     X = get_constant_date_array()
     enc = DatetimeEncoder(add_day_of_the_week=True)
     assert enc.fit_transform(X).shape[1] == 0
+
+def test_check_fitted_datetime_encoder():
+    """Test that calling transform before fit raises an error"""
+    X = get_datetime_array()[:, 0].reshape(-1, 1)
+    enc = DatetimeEncoder(add_day_of_the_week=True)
+    with pytest.raises(NotFittedError):
+        enc.transform(X)
+    
+    # Check that it works after fit
+    enc.fit(X)
+    enc.transform(X)
diff --git a/dirty_cat/tests/test_deduplicate.py b/dirty_cat/tests/test_deduplicate.py
new file mode 100644
index 0000000..572996a
--- /dev/null
+++ b/dirty_cat/tests/test_deduplicate.py
@@ -0,0 +1,124 @@
+import string
+from typing import List
+
+import numpy as np
+import pandas as pd
+import pytest
+from scipy.cluster.hierarchy import linkage
+from scipy.spatial.distance import squareform
+
+from dirty_cat._deduplicate import (
+    _create_spelling_correction,
+    _guess_clusters,
+    compute_ngram_distance,
+    deduplicate,
+)
+
+
+def generate_example_data(examples, entries_per_example, prob_mistake_per_letter, rng):
+    """Helper function to generate data consisting of multiple entries per example.
+    Characters are misspelled with probability `prob_mistake_per_letter`"""
+
+    data = []
+    for example, n_ex in zip(examples, entries_per_example):
+        len_ex = len(example)
+        # generate a 2D array of chars of size (n_ex, len_ex)
+        str_as_list = np.array([list(example)] * n_ex)
+        # randomly choose which characters are misspelled
+        idxes = np.where(
+            rng.random_sample(len(example[0]) * n_ex) < prob_mistake_per_letter
+        )[0]
+        # and randomly pick with which character to replace
+        replacements = [
+            string.ascii_lowercase[i]
+            for i in rng.choice(np.arange(26), len(idxes)).astype(int)
+        ]
+        # introduce spelling mistakes at right examples and char locations per example
+        str_as_list[idxes // len_ex, idxes % len_ex] = replacements
+        # go back to 1d array of strings
+        data.append(np.ascontiguousarray(str_as_list).view(f"U{len_ex}").ravel())
+    return np.concatenate(data)
+
+
+@pytest.mark.parametrize(
+    "entries_per_category, prob_mistake_per_letter",
+    [[[500, 100, 1500], 0.05], [[100, 100], 0.02], [[200, 50, 30, 200, 800], 0.01]],
+)
+def test_deduplicate(
+    entries_per_category: List[int],
+    prob_mistake_per_letter: float,
+    seed: int = 123,
+) -> None:
+    rng = np.random.RandomState(seed)
+
+    # hard coded to fix ground truth string similarities
+    clean_categories = [
+        "Example Category",
+        "Generic",
+        "Random Word",
+        "Pretty similar category",
+        "Final cluster",
+    ]
+    n_clusters = len(entries_per_category)
+    clean_categories = clean_categories[:n_clusters]
+    data = generate_example_data(
+        clean_categories, entries_per_category, prob_mistake_per_letter, rng
+    )
+    deduplicated_data = np.array(deduplicate(data, n_clusters=None))
+    assert deduplicated_data.shape[0] == data.shape[0]
+    recovered_categories = np.unique(deduplicated_data)
+    assert recovered_categories.shape[0] == n_clusters
+    assert np.isin(clean_categories, recovered_categories).all()
+    deduplicated_data = deduplicate(data, n_clusters=n_clusters)
+    translation_table = pd.Series(deduplicated_data, index=data)
+    translation_table = translation_table[
+        ~translation_table.index.duplicated(keep="first")
+    ]
+    assert np.isin(np.unique(deduplicated_data), recovered_categories).all()
+    assert np.alltrue(translation_table[data] == np.array(deduplicated_data))
+    deduplicated_other_analyzer = np.array(
+        deduplicate(data, n_clusters=n_clusters, analyzer="char")
+    )
+    unique_other_analyzer = np.unique(deduplicated_other_analyzer)
+    assert np.isin(unique_other_analyzer, recovered_categories).all()
+
+
+def test_compute_ngram_distance() -> None:
+    words = np.array(["aac", "aaa", "aaab", "aaa", "aaab", "aaa", "aaab", "aaa"])
+    distance = compute_ngram_distance(words)
+    distance = squareform(distance)
+    assert distance.shape[0] == words.shape[0]
+    assert np.allclose(np.diag(distance), 0)
+    for un_word in np.unique(words):
+        assert np.allclose(distance[words == un_word][:, words == un_word], 0)
+
+
+def test__guess_clusters() -> None:
+    words = np.array(["aac", "aaa", "aaab", "aaa", "aaab", "aaa", "aaab", "aaa"])
+    distance = compute_ngram_distance(words)
+    Z = linkage(distance, method="average")
+    n_clusters = _guess_clusters(Z, distance)
+    assert n_clusters == len(np.unique(words))
+
+
+def test__create_spelling_correction(seed: int = 123) -> None:
+    rng = np.random.RandomState(seed)
+    n_clusters = 3
+    samples_per_cluster = 10
+    counts = np.concatenate(
+        [rng.randint(0, 100, samples_per_cluster) for _ in range(n_clusters)]
+    )
+    clusters = (
+        np.repeat(np.arange(n_clusters), samples_per_cluster).astype("int").tolist()
+    )
+    spelling_correction = _create_spelling_correction(
+        counts.astype("str"),
+        counts,
+        clusters,
+    )
+    # check that the most common sample per cluster is chosen as the 'correct' spelling
+    for n in np.arange(n_clusters):
+        assert (
+            spelling_correction.values[clusters == n].astype("int")
+            == counts[clusters == n].max()
+        ).all()
diff --git a/dirty_cat/tests/test_feature_augmenter.py b/dirty_cat/tests/test_feature_augmenter.py
new file mode 100644
index 0000000..4840fa1
--- /dev/null
+++ b/dirty_cat/tests/test_feature_augmenter.py
@@ -0,0 +1,92 @@
+import pandas as pd
+import pytest
+
+from dirty_cat import FeatureAugmenter
+
+
+def test_feature_augmenter():
+    main_table = pd.DataFrame(
+        [
+            "France",
+            "Germany",
+            "Italy",
+        ],
+        columns=["Country"],
+    )
+
+    aux_table_1 = pd.DataFrame(
+        [
+            ["Germany", 84_000_000],
+            ["French Republic", 68_000_000],
+            ["Italia", 59_000_000],
+        ],
+        columns=["Country", "Population"],
+    )
+
+    aux_table_2 = pd.DataFrame(
+        [
+            ["France", 2937],
+            ["Italy", 2099],
+            ["Germany", 4223],
+            ["UK", 3186],
+        ],
+        columns=["Country name", "GDP (billion)"],
+    )
+
+    aux_table_3 = pd.DataFrame(
+        [
+            ["La France", "Paris"],
+            ["Italy", "Rome"],
+            ["Germany", "Berlin"],
+        ],
+        columns=["Countries", "Capital"],
+    )
+
+    aux_tables = [
+        (aux_table_1, "Country"),
+        (aux_table_2, "Country name"),
+        (aux_table_3, "Countries"),
+    ]
+
+    fa = FeatureAugmenter(tables=aux_tables, main_key="Country")
+
+    fa.fit(main_table)
+
+    number_of_cols = tuple(
+        map(
+            sum,
+            zip(
+                main_table.shape,
+                aux_table_1.shape,
+                aux_table_2.shape,
+                aux_table_3.shape,
+            ),
+        )
+    )[1]
+
+    big_table = fa.transform(main_table)
+    assert big_table.shape == (main_table.shape[0], number_of_cols)
+
+    big_table = fa.fit_transform(main_table)
+    assert big_table.shape == (main_table.shape[0], number_of_cols)
+
+    false_fa = FeatureAugmenter(tables=aux_tables, main_key="Countryy")
+
+    with pytest.raises(
+        ValueError,
+        match=r"Got main_key",
+    ):
+        false_fa.fit(main_table)
+
+    false_aux_tables = [
+        (aux_table_1, "Countrys"),
+        (aux_table_2, "Country name"),
+        (aux_table_3, "Countries"),
+    ]
+
+    false_fa2 = FeatureAugmenter(tables=false_aux_tables, main_key="Country")
+    with pytest.raises(
+        ValueError,
+        match=r"Got column key",
+    ):
+        false_fa2.fit(main_table)
diff --git a/dirty_cat/tests/test_gap_encoder.py b/dirty_cat/tests/test_gap_encoder.py
index e9be30f..e39e798 100644
--- a/dirty_cat/tests/test_gap_encoder.py
+++ b/dirty_cat/tests/test_gap_encoder.py
@@ -2,6 +2,7 @@ import numpy as np
 import pandas as pd
 import pytest
 from sklearn import __version__ as sklearn_version
+from sklearn.exceptions import NotFittedError
 
 from dirty_cat import GapEncoder
 from dirty_cat._utils import parse_version
@@ -195,3 +196,14 @@ def test_missing_values(missing: str) -> None:
             r"'error' or 'zero_impute', got 'aaa'",
         ):
             enc.fit_transform(observations)
+
+def test_check_fitted_gap_encoder():
+    """Test that calling transform before fit raises an error"""
+    X = np.array([["alice"], ["bob"]])
+    enc = GapEncoder(n_components=2, random_state=42)
+    with pytest.raises(NotFittedError):
+        enc.transform(X)
+    
+    # Check that it works after fit
+    enc.fit(X)
+    enc.transform(X)
diff --git a/dirty_cat/tests/test_minhash_encoder.py b/dirty_cat/tests/test_minhash_encoder.py
index e88d380..95617c4 100644
--- a/dirty_cat/tests/test_minhash_encoder.py
+++ b/dirty_cat/tests/test_minhash_encoder.py
@@ -6,6 +6,7 @@ import numpy as np
 import pandas as pd
 import pytest
 from sklearn.utils._testing import assert_array_equal, skip_if_no_parallel
+from sklearn.exceptions import NotFittedError
 
 from dirty_cat import MinHashEncoder
 
@@ -242,3 +243,15 @@ def test_correct_arguments():
     with pytest.raises(ValueError, match=r"n_components should be even"):
         encoder = MinHashEncoder(n_components=3, minmax_hash=True)
         encoder.fit_transform(X)
+
+def test_check_fitted_minhash_encoder():
+    """Test that calling transform before fit raises an error"""
+    encoder = MinHashEncoder(n_components=3)
+    X = np.array(["a", "b", "c", "d", "e", "f", "g", "h"])[:, None]
+    with pytest.raises(NotFittedError):
+        encoder.transform(X)
+    
+    # Check that it works after fitting
+    encoder.fit(X)
+    encoder.transform(X)
+
diff --git a/dirty_cat/tests/test_similarity_encoder.py b/dirty_cat/tests/test_similarity_encoder.py
index 292c7c2..51bc929 100644
--- a/dirty_cat/tests/test_similarity_encoder.py
+++ b/dirty_cat/tests/test_similarity_encoder.py
@@ -4,6 +4,7 @@ import numpy as np
 import numpy.testing
 import pytest
 from sklearn import __version__ as sklearn_version
+from sklearn.exceptions import NotFittedError
 
 from dirty_cat import SimilarityEncoder
 from dirty_cat._similarity_encoder import get_kmeans_prototypes, ngram_similarity_matrix
@@ -385,3 +386,12 @@ def test_get_features() -> None:
         "x0_}",
         "x0_~",
     ]
+
+def test_check_fitted_super_vectorizer():
+    """Test that calling transform before fit raises an error"""
+    sim_enc = SimilarityEncoder()
+    X = np.array(["%s" % chr(i) for i in range(32, 127)]).reshape((-1, 1))
+    with pytest.raises(NotFittedError):
+        sim_enc.transform(X)
+    sim_enc.fit(X)
+    sim_enc.transform(X)
diff --git a/dirty_cat/tests/test_super_vectorizer.py b/dirty_cat/tests/test_super_vectorizer.py
index ce600c5..0806c0d 100644
--- a/dirty_cat/tests/test_super_vectorizer.py
+++ b/dirty_cat/tests/test_super_vectorizer.py
@@ -7,6 +7,7 @@ import sklearn
 from sklearn.exceptions import NotFittedError
 from sklearn.preprocessing import StandardScaler
 from sklearn.utils.validation import check_is_fitted
+from sklearn.exceptions import NotFittedError
 
 from dirty_cat import GapEncoder, SuperVectorizer
 from dirty_cat._utils import parse_version
@@ -445,3 +446,15 @@ def test_passthrough():
     dirty_flat_trans_df = X_enc_dirty.to_numpy().ravel().tolist()
     assert all(map(_is_equal, zip(dirty_flat_df, dirty_flat_trans_df)))
     assert (X_clean.to_numpy() == X_enc_clean.to_numpy()).all()
+
+def test_check_fitted_super_vectorizer():
+    """Test that calling transform before fit raises an error"""
+    X = _get_clean_dataframe()
+    sv = SuperVectorizer()
+    with pytest.raises(NotFittedError):
+        sv.transform(X)
+    
+    # Test that calling transform after fit works
+    sv.fit(X)
+    sv.transform(X)
+
diff --git a/dirty_cat/tests/test_target_encoder.py b/dirty_cat/tests/test_target_encoder.py
index e45b396..cf64192 100644
--- a/dirty_cat/tests/test_target_encoder.py
+++ b/dirty_cat/tests/test_target_encoder.py
@@ -1,5 +1,6 @@
 import numpy as np
 import pytest
+from sklearn.exceptions import NotFittedError
 
 from dirty_cat import _target_encoder
 
@@ -275,3 +276,15 @@ def test_errors():
         enc4.transform(X2)
     with pytest.raises(ValueError, match="Found unknown categories "):
         enc4.transform(X3)
+
+def test_check_fitted_target_encoder():
+    """Test that calling transform before fit raises an error"""
+    enc = _target_encoder.TargetEncoder()
+    X = [[1], [2], [2], [3], [4]]
+    y = np.array([1, 2, 3, 4, 5])
+
+    with pytest.raises(NotFittedError, match="This TargetEncoder instance is not"):
+        enc.transform(X)
+    
+    enc.fit(X, y)
+    enc.transform(X)