Repository: dirty-cat/dirty_cat
Pull request #464 (2023-01-17 17:13:15)

Run pre-commit hooks on all files
I ran `pre-commit run --all-files` with dirty-cat's config (i.e for hooks check-yaml, end-of-file-fixer, trailing-whitespace, black, flake8 and isort).


Patch:
diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md
index ed69f9c..d24e63e 100644
--- a/.github/ISSUE_TEMPLATE/documentation.md
+++ b/.github/ISSUE_TEMPLATE/documentation.md
@@ -10,4 +10,4 @@ assignees: ''
 <!--List pages that would be impacted by the enhancement-->
 
 Affected pages:
-- 
+-
diff --git a/.gitignore b/.gitignore
index dc2445f..746bffc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,4 +64,3 @@ doc/CONTRIBUTING.rst
 
 # Pkl files for benchmarks
 benchmarks/*.pkl
-
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 71fc470..1183af2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,12 +3,16 @@ repos:
     rev: v4.3.0
     hooks:
     -   id: check-yaml
+        exclude: doc/
     -   id: end-of-file-fixer
+        exclude: doc/
     -   id: trailing-whitespace
+        exclude: doc/
 -   repo: https://github.com/psf/black
     rev: 22.8.0
     hooks:
     -   id: black
+        exclude: doc/
 -   repo: https://github.com/pycqa/flake8
     rev: 5.0.4
     hooks:
diff --git a/CHANGES.rst b/CHANGES.rst
index ab91735..da0935d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -2,8 +2,11 @@
 
 .. currentmodule:: dirty_cat
 
-Release 0.4.0 (upcoming)
-=========================
+Release 0.5.0
+==============
+
+Release 0.4.0 (beta)
+====================
 
 Major changes
 -------------
@@ -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>`
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 2301656..b4e1709 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -13,4 +13,3 @@ all priceless contributions.
 
 We abide by the principles of openness, respect, and consideration of others of
 the Python Software Foundation: https://www.python.org/psf/codeofconduct/
-
diff --git a/benchmarks/bench_minhash.py b/benchmarks/bench_minhash.py
index bbffa6f..d891c51 100644
--- a/benchmarks/bench_minhash.py
+++ b/benchmarks/bench_minhash.py
@@ -66,7 +66,7 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
     ----------
     hash_dict_ : LRUDict
         Computed hashes.
-    
+
     Examples
     --------
     >>> enc = MinHashEncoder(n_components=5)
@@ -343,8 +343,9 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
                     unique_x[idx_slice],
                     hash_func,
                 )
-                for idx_slice in gen_even_slices(len(unique_x), 
-                n_jobs * self.batch_per_job)
+                for idx_slice in gen_even_slices(
+                    len(unique_x), n_jobs * self.batch_per_job
+                )
             )
             # Match the hashes of the unique value to the original values
             X_out = np.concatenate(unique_x_trans)[indices_x].reshape(
@@ -395,8 +396,9 @@ def benchmark(
     batch_per_job: int,
 ) -> None:
     X = data[dataset_size]
-    MinHashEncoder(batch=batched, n_jobs=n_jobs, 
-    batch_per_job=batch_per_job).fit(X).transform(X)
+    MinHashEncoder(batch=batched, n_jobs=n_jobs, batch_per_job=batch_per_job).fit(
+        X
+    ).transform(X)
 
 
 def plot(res: pd.DataFrame):
@@ -408,17 +410,25 @@ def plot(res: pd.DataFrame):
         memories = eval(str(ser["memory"]))
         _, _, kwargs = parse_func_repr(ser["call"])
         for time, memory in zip(times, memories):
-            rows.append((kwargs["batched"], kwargs["batch_per_job"], 
-            kwargs["n_jobs"], time, memory))
+            rows.append(
+                (
+                    kwargs["batched"],
+                    kwargs["batch_per_job"],
+                    kwargs["n_jobs"],
+                    time,
+                    memory,
+                )
+            )
 
-    df = pd.DataFrame(rows, columns=["batched", "batch_per_job", 
-    "n_jobs", "time", "memory"])
+    df = pd.DataFrame(
+        rows, columns=["batched", "batch_per_job", "n_jobs", "time", "memory"]
+    )
 
     # Create a new columns merging batched and batch_per_job
     # If batch is False, ignore batch_per_job
     df["config"] = df.apply(
         lambda row: f"batched={row['batched']}, batch_per_job={row['batch_per_job']}"
-        if row["batched"] == 'True'
+        if row["batched"] == "True"
         else "batched=False",
         axis=1,
     )
diff --git a/benchmarks/get_results.py b/benchmarks/get_results.py
index 5406673..94a6cfe 100644
--- a/benchmarks/get_results.py
+++ b/benchmarks/get_results.py
@@ -12,16 +12,16 @@ from pathlib import Path
 
 here = Path(__file__).parent
 
-expected_end = '_results.csv'
+expected_end = "_results.csv"
 
 param_name = "cardinality_threshold"
 for file in here.iterdir():
     if file.name.endswith(expected_end):
         df: pd.DataFrame = pd.read_csv(file)
-        name = file.name[:-len(expected_end)]
+        name = file.name[: -len(expected_end)]
 
-        card_unq = np.unique(df['param_sv__cardinality_threshold'])
-        comp_unq = np.unique(df['param_sv__high_card_str_transformer__n_components'])
+        card_unq = np.unique(df["param_sv__cardinality_threshold"])
+        comp_unq = np.unique(df["param_sv__high_card_str_transformer__n_components"])
 
         card_amnt = len(card_unq)
         comp_amnt = len(comp_unq)
@@ -35,18 +35,18 @@ for file in here.iterdir():
         ax.set_xticks(np.arange(comp_amnt))
         ax.set_yticks(np.arange(card_amnt))
         # ... and label them with the respective list entries
-        ax.set_xticklabels([f'n_components={v}' for v in comp_unq])
-        ax.set_yticklabels([f'cardinality={v}' for v in card_unq])
+        ax.set_xticklabels([f"n_components={v}" for v in comp_unq])
+        ax.set_yticklabels([f"cardinality={v}" for v in card_unq])
 
         # Rotate the tick labels and set their alignment.
-        plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
-                 rotation_mode="anchor")
+        plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")
 
         # Loop over data dimensions and create text annotations.
         for i in range(card_amnt):
             for j in range(comp_amnt):
-                text = ax.text(j, i, round(scores[i, j], 3),
-                               ha="center", va="center", color="w")
+                text = ax.text(
+                    j, i, round(scores[i, j], 3), ha="center", va="center", color="w"
+                )
 
         ax.set_title(name)
         fig.tight_layout()
diff --git a/benchmarks/similarity_scores_time_benchmark.py b/benchmarks/similarity_scores_time_benchmark.py
index dc782cc..3010e16 100644
--- a/benchmarks/similarity_scores_time_benchmark.py
+++ b/benchmarks/similarity_scores_time_benchmark.py
@@ -12,7 +12,8 @@ Parameters that are modified:
 
 # We filter out the warning asking us to specify the solver in the logistic_regression
 import warnings
-warnings.simplefilter(action='ignore', category=FutureWarning)
+
+warnings.simplefilter(action="ignore", category=FutureWarning)
 
 from time import time
 
@@ -30,10 +31,17 @@ from dirty_cat.datasets import fetch_traffic_violations
 data = fetch_traffic_violations()
 
 col_to_ohe = [
-    "alcohol", "arrest_type", "belts",
-    "commercial_license", "commercial_vehicle",
-    "fatal", "gender", "hazmat",
-    "property_damage", "race", "work_zone",
+    "alcohol",
+    "arrest_type",
+    "belts",
+    "commercial_license",
+    "commercial_vehicle",
+    "fatal",
+    "gender",
+    "hazmat",
+    "property_damage",
+    "race",
+    "work_zone",
 ]
 
 col_to_pass = ["year"]
@@ -51,25 +59,43 @@ X.reset_index(inplace=True, drop=True)
 y.reset_index(inplace=True, drop=True)
 
 transformers = [
-    ('one_hot', OneHotEncoder(sparse=False, handle_unknown='ignore'), col_to_ohe),
+    ("one_hot", OneHotEncoder(sparse=False, handle_unknown="ignore"), col_to_ohe),
     ("pass", "passthrough", col_to_pass),
 ]
 
 
-def benchmark(X_b, y_b, strat='k-means', limit=50000, n_proto=100, hash_dim=None, ngram_range=(3, 3)):
+def benchmark(
+    X_b,
+    y_b,
+    strat="k-means",
+    limit=50000,
+    n_proto=100,
+    hash_dim=None,
+    ngram_range=(3, 3),
+):
     X_b = X_b[:limit].copy()
     y_b = y_b[:limit].copy()
 
-    if strat == 'k-means':
-        sim_enc = SimilarityEncoder(ngram_range=ngram_range, categories='k-means',
-                                    hashing_dim=hash_dim, n_prototypes=n_proto, random_state=3498)
+    if strat == "k-means":
+        sim_enc = SimilarityEncoder(
+            ngram_range=ngram_range,
+            categories="k-means",
+            hashing_dim=hash_dim,
+            n_prototypes=n_proto,
+            random_state=3498,
+        )
     else:
-        sim_enc = SimilarityEncoder(ngram_range=ngram_range, categories='most_frequent',
-                                    hashing_dim=hash_dim, n_prototypes=n_proto, random_state=3498)
+        sim_enc = SimilarityEncoder(
+            ngram_range=ngram_range,
+            categories="most_frequent",
+            hashing_dim=hash_dim,
+            n_prototypes=n_proto,
+            random_state=3498,
+        )
 
     column_trans = ColumnTransformer(
-        transformers=transformers + [('sim_enc', sim_enc, ['description'])],
-        remainder='drop'
+        transformers=transformers + [("sim_enc", sim_enc, ["description"])],
+        remainder="drop",
     )
 
     t0 = time()
@@ -86,29 +112,33 @@ def benchmark(X_b, y_b, strat='k-means', limit=50000, n_proto=100, hash_dim=None
     return t_score_1, m_score, t_score_2
 
 
-def plot(bench, title=''):
-    sns.set(style='ticks', palette='muted')
-    hash_dims = ['Count', '2 ** 14', '2 ** 16', '2 ** 18', '2 ** 20']
+def plot(bench, title=""):
+    sns.set(style="ticks", palette="muted")
+    hash_dims = ["Count", "2 ** 14", "2 ** 16", "2 ** 18", "2 ** 20"]
     scores = []
     vectorizer = []
     strategy = []
 
     for i, e in enumerate(bench):
         vectorizer.extend([hash_dims[i % 5]] * (2 * len(e[0][1])))
-        strategy.extend(['k-means'] * len(e[0][1]))
-        strategy.extend(['most-frequent'] * len(e[1][1]))
+        strategy.extend(["k-means"] * len(e[0][1]))
+        strategy.extend(["most-frequent"] * len(e[1][1]))
         scores.extend(e[0][1])
         scores.extend(e[1][1])
 
-    df = pd.DataFrame(columns=['vectorizer', 'strategy', 'score'])
-    df['vectorizer'] = vectorizer
-    df['strategy'] = strategy
-    df['score'] = scores
+    df = pd.DataFrame(columns=["vectorizer", "strategy", "score"])
+    df["vectorizer"] = vectorizer
+    df["strategy"] = strategy
+    df["score"] = scores
 
     first = plt.figure()
-    ax = sns.boxplot(x='vectorizer', y='score', hue='strategy', data=df)
-    ax.set(title=title, xlabel='Vectorizer used', ylabel='Mean score on 10 cross validations')
-    ax.legend(loc='center left', bbox_to_anchor=(1, 0.5))
+    ax = sns.boxplot(x="vectorizer", y="score", hue="strategy", data=df)
+    ax.set(
+        title=title,
+        xlabel="Vectorizer used",
+        ylabel="Mean score on 10 cross validations",
+    )
+    ax.legend(loc="center left", bbox_to_anchor=(1, 0.5))
     first.tight_layout()
 
     vectorizer.clear()
@@ -118,41 +148,65 @@ def plot(bench, title=''):
 
     for i, e in enumerate(bench):
         vectorizer.extend([hash_dims[i % 5]] * 4)
-        strategy.extend(['K-means vect', 'K-means X-val', 'MF vect', 'MF X-val'])
+        strategy.extend(["K-means vect", "K-means X-val", "MF vect", "MF X-val"])
         times.extend([e[0][0], e[0][2] / 20, e[1][0], e[1][2] / 20])
 
-    df = pd.DataFrame(columns=['vectorizer', 'strategy/operation', 'time'])
-    df['vectorizer'] = vectorizer
-    df['strategy/operation'] = strategy
-    df['time'] = times
+    df = pd.DataFrame(columns=["vectorizer", "strategy/operation", "time"])
+    df["vectorizer"] = vectorizer
+    df["strategy/operation"] = strategy
+    df["time"] = times
 
     second = plt.figure()
-    ax1 = sns.barplot(x='vectorizer', y='time', hue='strategy/operation', data=df)
-    ax1.set(title=title, xlabel='Vectorizer used', ylabel='Time in seconds')
-    ax1.legend(loc='center left', bbox_to_anchor=(1, 0.5))
+    ax1 = sns.barplot(x="vectorizer", y="time", hue="strategy/operation", data=df)
+    ax1.set(title=title, xlabel="Vectorizer used", ylabel="Time in seconds")
+    ax1.legend(loc="center left", bbox_to_anchor=(1, 0.5))
     second.tight_layout()
 
-    title = title.replace(' ', '_').replace(':', '-').replace(',', '_').lower()
-    first.savefig(title + '_score.png')
-    second.savefig(title + '_time.png')
+    title = title.replace(" ", "_").replace(":", "-").replace(",", "_").lower()
+    first.savefig(title + "_score.png")
+    second.savefig(title + "_time.png")
     # first.show()
     # second.show(t)
 
 
 def loop(proto):
-    limits = sorted([X['description'].nunique(), 10000, 20000, 50000, 100000])
-    hash_dims = [None, 2 ** 14, 2 ** 16, 2 ** 18, 2 ** 20]
+    limits = sorted([X["description"].nunique(), 10000, 20000, 50000, 100000])
+    hash_dims = [None, 2**14, 2**16, 2**18, 2**20]
     bench = list()
     ngram = [(3, 3), (2, 4)]
     for limit in limits:
         for r in ngram:
             for h in hash_dims:
-                bench.append((benchmark(X, y, strat='k-means', limit=limit, n_proto=proto, hash_dim=h, ngram_range=r),
-                              benchmark(X, y, strat='most-frequent', limit=limit, n_proto=proto, hash_dim=h, ngram_range=r)))
-            title = 'N-gram range: %s, Rows: %d, Prototypes: %d, 20 CV' % (r.__str__(), limit, proto)
+                bench.append(
+                    (
+                        benchmark(
+                            X,
+                            y,
+                            strat="k-means",
+                            limit=limit,
+                            n_proto=proto,
+                            hash_dim=h,
+                            ngram_range=r,
+                        ),
+                        benchmark(
+                            X,
+                            y,
+                            strat="most-frequent",
+                            limit=limit,
+                            n_proto=proto,
+                            hash_dim=h,
+                            ngram_range=r,
+                        ),
+                    )
+                )
+            title = "N-gram range: %s, Rows: %d, Prototypes: %d, 20 CV" % (
+                r.__str__(),
+                limit,
+                proto,
+            )
             plot(bench, title)
             bench.clear()
 
 
-if __name__ == '__main__':
-    loop(100)
\ No newline at end of file
+if __name__ == "__main__":
+    loop(100)
diff --git a/benchmarks/supervectorizer_tuning.py b/benchmarks/supervectorizer_tuning.py
index f726c0f..cd1031f 100644
--- a/benchmarks/supervectorizer_tuning.py
+++ b/benchmarks/supervectorizer_tuning.py
@@ -11,9 +11,15 @@ from sklearn.model_selection import GridSearchCV
 from sklearn.pipeline import Pipeline
 
 from dirty_cat import SuperVectorizer
-from dirty_cat.datasets import fetch_open_payments, fetch_drug_directory, \
-    fetch_road_safety, fetch_midwest_survey, fetch_medical_charge, \
-    fetch_employee_salaries, fetch_traffic_violations
+from dirty_cat.datasets import (
+    fetch_open_payments,
+    fetch_drug_directory,
+    fetch_road_safety,
+    fetch_midwest_survey,
+    fetch_medical_charge,
+    fetch_employee_salaries,
+    fetch_traffic_violations,
+)
 
 from pathlib import Path
 from functools import wraps
@@ -23,25 +29,25 @@ from typing import List, Tuple
 
 def get_classification_datasets() -> List[Tuple[dict, str]]:
     return [
-        (fetch_open_payments(), 'open_payments'),
+        (fetch_open_payments(), "open_payments"),
         # (fetch_drug_directory(), 'drug_directory),
-        (fetch_road_safety(), 'road_safety'),
-        (fetch_midwest_survey(), 'midwest_survey'),
-        (fetch_traffic_violations(), 'traffic_violations'),
+        (fetch_road_safety(), "road_safety"),
+        (fetch_midwest_survey(), "midwest_survey"),
+        (fetch_traffic_violations(), "traffic_violations"),
     ]
 
 
 def get_regression_datasets() -> List[Tuple[dict, str]]:
     return [
-        (fetch_medical_charge(), 'medical_charge'),
-        (fetch_employee_salaries(), 'employee_salaries'),
+        (fetch_medical_charge(), "medical_charge"),
+        (fetch_employee_salaries(), "employee_salaries"),
     ]
 
 
 def get_dataset(info) -> Tuple[pd.DataFrame, pd.Series]:
-    df = pd.read_csv(info['path'], **info['read_csv_kwargs'])
-    y = df[info['y']]
-    X = df.drop(info['y'], axis=1).astype(str)
+    df = pd.read_csv(info["path"], **info["read_csv_kwargs"])
+    y = df[info["y"]]
+    X = df.drop(info["y"], axis=1).astype(str)
     return X, y
 
 
@@ -53,12 +59,12 @@ def set_logging(func):
         logger = logging.getLogger()
         logger.setLevel(logging_level)
 
-        formatter = logging.Formatter('%(asctime)s - [%(levelname)s] %(message)s')
-        formatter.datefmt = '%m/%d/%Y %H:%M:%S'
+        formatter = logging.Formatter("%(asctime)s - [%(levelname)s] %(message)s")
+        formatter.datefmt = "%m/%d/%Y %H:%M:%S"
 
-        path = Path(__file__).parent / f'tuning_{str(datetime.now())[:10]}.log'
+        path = Path(__file__).parent / f"tuning_{str(datetime.now())[:10]}.log"
 
-        fh = logging.FileHandler(filename=path, mode='w')
+        fh = logging.FileHandler(filename=path, mode="w")
         fh.setLevel(logging_level)
         fh.setFormatter(formatter)
 
@@ -70,28 +76,35 @@ def set_logging(func):
         # logger.addHandler(sh)
 
         return func(*args, **kwargs)
+
     return wrapper
 
 
 @set_logging
 def main():
-    logging.info('Launching !')
+    logging.info("Launching !")
 
     card_possibilities = [20, 30, 40, 50]
     n_comp_possibilities = [10, 30, 50]
 
-    logging.debug('Creating pipelines')
-    regression_pipeline = Pipeline([
-        ('sv', SuperVectorizer()),
-        ('estimator', RandomForestRegressor()),
-    ])
-    classification_pipeline = Pipeline([
-        ('sv', SuperVectorizer()),
-        ('estimator', RandomForestClassifier()),
-    ])
-
-    logging.debug(f'With cardinality possibilities: {card_possibilities} '
-                  f'and n_components possibilities: {n_comp_possibilities}')
+    logging.debug("Creating pipelines")
+    regression_pipeline = Pipeline(
+        [
+            ("sv", SuperVectorizer()),
+            ("estimator", RandomForestRegressor()),
+        ]
+    )
+    classification_pipeline = Pipeline(
+        [
+            ("sv", SuperVectorizer()),
+            ("estimator", RandomForestClassifier()),
+        ]
+    )
+
+    logging.debug(
+        f"With cardinality possibilities: {card_possibilities} "
+        f"and n_components possibilities: {n_comp_possibilities}"
+    )
     for pipeline, datasets in zip(
         [
             regression_pipeline,
@@ -100,25 +113,25 @@ def main():
         [
             get_regression_datasets(),
             get_classification_datasets(),
-        ]
+        ],
     ):
         for info, name in datasets:
             X, y = get_dataset(info)
-            if name != 'traffic_violations':
+            if name != "traffic_violations":
                 continue
 
-            csv_path = Path('.').resolve() / f'{name}_results.csv'
+            csv_path = Path(".").resolve() / f"{name}_results.csv"
             if csv_path.exists():
                 # If the results already exist, we'll skip to the next
-                logging.debug(f'Skipping {name} as {csv_path!s} was found')
+                logging.debug(f"Skipping {name} as {csv_path!s} was found")
                 continue
 
-            logging.debug(f'Running search on {name}')
+            logging.debug(f"Running search on {name}")
             grid = GridSearchCV(
                 estimator=pipeline,
                 param_grid={
-                    'sv__cardinality_threshold': card_possibilities,
-                    'sv__high_card_str_transformer__n_components': n_comp_possibilities,
+                    "sv__cardinality_threshold": card_possibilities,
+                    "sv__high_card_str_transformer__n_components": n_comp_possibilities,
                 },
                 n_jobs=30,
             )
@@ -126,8 +139,8 @@ def main():
 
             df = pd.DataFrame(grid.cv_results_)
             df.to_csv(csv_path)
-            logging.info(f'Saved search results in {csv_path!s}')
+            logging.info(f"Saved search results in {csv_path!s}")
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()
diff --git a/dirty_cat/VERSION.txt b/dirty_cat/VERSION.txt
index ca91a08..88b0e14 100644
--- a/dirty_cat/VERSION.txt
+++ b/dirty_cat/VERSION.txt
@@ -1,1 +1,1 @@
-0.4.dev0
+0.4.0b1
diff --git a/dirty_cat/__init__.py b/dirty_cat/__init__.py
index b6bf6d3..40f30b8 100644
--- a/dirty_cat/__init__.py
+++ b/dirty_cat/__init__.py
@@ -16,6 +16,7 @@ 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
@@ -28,13 +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/_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..4aa3d17 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)
@@ -873,7 +875,7 @@ class GapEncoder(BaseEstimator, TransformerMixin):
 
                 - 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.
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..83c6511 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
@@ -219,7 +220,7 @@ class SimilarityEncoder(OneHotEncoder):
            categorical variable
         - 'k-means' : Computes the K nearest neighbors of K-mean centroids
            in order to choose the prototype categories
-           
+
         The categories used can be found in the ``categories_`` attribute.
     dtype : number type, default np.float64
         Desired dtype of output.
@@ -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 92f3ace..0dfc30f 100644
--- a/dirty_cat/datasets/__init__.py
+++ b/dirty_cat/datasets/__init__.py
@@ -10,9 +10,14 @@ from ._fetching import (
     fetch_traffic_violations,
     fetch_world_bank_indicator,
     get_data_dir,
+)
+
+
+from ._generating import (
     make_deduplication_data,
 )
 
+
 __all__ = [
     "DatasetAll",
     "DatasetInfoOnly",
diff --git a/dirty_cat/datasets/_fetching.py b/dirty_cat/datasets/_fetching.py
index 41c61ca..b0eeb77 100644
--- a/dirty_cat/datasets/_fetching.py
+++ b/dirty_cat/datasets/_fetching.py
@@ -820,47 +820,3 @@ def fetch_world_bank_indicator(
     )
 
 
-def make_deduplication_data(
-    examples: List[str], entries_per_example: List[int], prob_mistake_per_letter: float
-) -> 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
-
-    Returns
-    -------
-    List[str]
-        list of duplicated examples with spelling mistakes
-    """
-
-    import string
-
-    import numpy as np
-
-    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(
-            np.random.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/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
index 95b8677..0c3fcf8 100644
--- a/examples/06_deduplication.py
+++ b/examples/06_deduplication.py
@@ -2,25 +2,26 @@
 Deduplicating misspelled categories with deduplicate
 ====================================================
 
-A common step in data analyses is grouping or analyzing data conditional on a
-categorical variable. In real world datasets, there often will be slight
-misspellings in the category names: This happens when, for example, data input
-*should* use a drop down menu, but users are forced to input the category name
-by hand. Misspellings happen and analyzing the resulting data using a simple
-`GROUP BY` is not possible anymore.
-
-This problem is however the perfect use case of *unsupervised learning*, a
-category of various statical methods that 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.
+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
-# ----------
+# An example dataset
+# -------------------
 #
 # Imagine the following example:
 # As a data scientist, our job is to analyze the data from a hospital ward.
@@ -34,13 +35,8 @@ in an unsupervised manner.
 
 
 import numpy as np
-import pandas as pd
-import matplotlib.pyplot as plt
 from dirty_cat.datasets import make_deduplication_data
 
-# set seed for reproducibility
-np.random.seed(123)
-
 # our three medication names
 medications = ["Contrivan", "Genericon", "Zipholan"]
 entries_per_medications = [500, 100, 1500]
@@ -48,18 +44,25 @@ entries_per_medications = [500, 100, 1500]
 # 5% probability of a typo per letter
 prob_mistake_per_letter = 0.05
 
-data = make_deduplication_data(
-    medications, entries_per_medications, prob_mistake_per_letter
+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(data, return_counts=True)
+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")
@@ -73,14 +76,6 @@ plt.ylabel("Counts")
 # name will be closest to either the correctly or incorrectly spelled orginal
 # medication name - and therefore form clusters.
 
-
-from dirty_cat import deduplicate
-from dirty_cat._deduplicate import compute_ngram_distance
-from scipy.spatial.distance import squareform
-
-ngram_distances = compute_ngram_distance(unique_examples)
-square_distances = squareform(ngram_distances)
-
 ###############################################################################
 # We can visualize the pair-wise distance between all medication names
 # --------------------------------------------------------------------
@@ -90,6 +85,12 @@ square_distances = squareform(ngram_distances)
 # 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))
@@ -98,15 +99,19 @@ sns.heatmap(
 )
 
 ###############################################################################
-# Clustering to suggest corrections of misspelled names
+# 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(data)
+deduplicated_data = deduplicate(duplicated_names)
 
 ###############################################################################
 # We can visualize the distribution of categories in the deduplicated data:
@@ -131,7 +136,7 @@ plt.ylabel("Counts")
 # (potentially) correct categories as values.
 
 # create a table that maps original -> corrected categories
-translation_table = pd.Series(deduplicated_data, index=data)
+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")]
@@ -154,8 +159,10 @@ def print_corrections(spell_correct):
 print_corrections(translation_table)
 
 ###############################################################################
-# In case we want to adapt the translation table post-hoc we can easily do so:
+# 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["Gszericon"] = "Completely new category"
-new_deduplicated_data = translation_table[data]
-assert (new_deduplicated_data == "Completely new category").sum() > 0
+translation_table.iloc[-1] = "Completely new category"
+new_deduplicated_names = translation_table[duplicated_names]
+assert (new_deduplicated_names == "Completely new category").sum() > 0
diff --git a/examples/README.txt b/examples/README.txt
index 8d0935a..822da6f 100644
--- a/examples/README.txt
+++ b/examples/README.txt
@@ -1,2 +1,2 @@
 Using dirty_cat
-===============
\ No newline at end of file
+===============


Test patch:
diff --git a/dirty_cat/datasets/tests/test_fetching.py b/dirty_cat/datasets/tests/test_fetching.py
index 721c29c..561dcaa 100644
--- a/dirty_cat/datasets/tests/test_fetching.py
+++ b/dirty_cat/datasets/tests/test_fetching.py
@@ -29,7 +29,6 @@ from dirty_cat.datasets._fetching import (
 from dirty_cat.datasets._fetching import (
     fetch_world_bank_indicator as fetch_world_bank_indicator,
 )
-from dirty_cat.datasets._fetching import make_deduplication_data
 from dirty_cat.datasets._utils import get_data_dir as _get_data_dir
 
 
@@ -456,23 +455,3 @@ def test_fetch_world_bank_indicator():
         shutil.rmtree(path=str(test_data_dir), ignore_errors=True)
 
 
-def test_make_deduplication_data():
-    import numpy as np
-
-    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/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_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..0aa504f 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
 
@@ -192,6 +193,7 @@ class DummyBackend(DEFAULT_JOBLIB_BACKEND):  # type: ignore
     The `count` attribute is used to check that the backend is used.
     Copied from https://github.com/scikit-learn/scikit-learn/blob/36958fb240fbe435673a9e3c52e769f01f36bec0/sklearn/ensemble/tests/test_forest.py  # noqa
     """
+
     def __init__(self, *args, **kwargs):
         self.count = 0
         super().__init__(*args, **kwargs)
@@ -216,7 +218,7 @@ def test_backend_respected():
 
     with joblib.parallel_backend("testing") as (ba, n_jobs):
         encoder.fit_transform(X)
-        
+
     assert ba.count > 0
 
 
@@ -242,3 +244,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)