Repository: dirty-cat/dirty_cat
Pull request #471 (2023-01-19 08:53:31)

DOC Add simple reproducible example to deduplicate
Fix part of #373 


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..58dc8c3 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:
@@ -16,7 +20,7 @@ repos:
         files: dirty_cat/
         types: [file, python]
 -   repo: https://github.com/PyCQA/isort
-    rev: 5.10.1
+    rev: 5.12.0
     hooks:
     -   id: isort
         files: dirty_cat/
diff --git a/CHANGES.rst b/CHANGES.rst
index da0935d..07665d3 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -13,13 +13,13 @@ 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>`
+  :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>`
+  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, the indicator ID that can be found there.
+  :pr:`291` by :user:`Jovan Stojanovic <jovan-stojanovic>`
 * Unnecessary API has been made private: everything (files, functions, classes)
   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
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/_deduplicate.py b/dirty_cat/_deduplicate.py
index fda228f..8300d6a 100644
--- a/dirty_cat/_deduplicate.py
+++ b/dirty_cat/_deduplicate.py
@@ -166,6 +166,43 @@ def deduplicate(
     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.
+
+    Examples
+    --------
+    >>> from dirty_cat.datasets import make_deduplication_data
+    >>> duplicated = make_deduplication_data(examples=['black', 'white'],
+                                             entries_per_example=[5, 5],
+                                             prob_mistake_per_letter=0.3,
+                                             random_state=42)
+
+    >>> duplicated
+    ['blacn', 'black', 'black', 'black', 'black',
+     'hvite', 'white', 'white', 'white', 'white']
+
+    To deduplicate the data, we can build a correspondance matrix:
+
+    >>> deduplicate_correspondence = deduplicate(duplicated)
+    >>> deduplicate_correspondence
+    blacn    black
+    black    black
+    black    black
+    black    black
+    black    black
+    hvite    white
+    white    white
+    white    white
+    white    white
+    white    white
+    dtype: object
+
+    The translation table above is actually a series, giving the deduplicated values, and indexed by the original values. A deduplicated version of the initial list can easily be created:
+
+    >>> deduplicated = list(deduplicate_correspondence)
+    >>> deduplicated
+    ['black', 'black', 'black', 'black', 'black',
+    'white', 'white', 'white', 'white', 'white']
+
+    We have our dirty categories deduplicated.
     """
     unique_words, counts = np.unique(data, return_counts=True)
     distance_mat = compute_ngram_distance(
diff --git a/dirty_cat/_feature_augmenter.py b/dirty_cat/_feature_augmenter.py
index 7dcf36d..ec5afef 100644
--- a/dirty_cat/_feature_augmenter.py
+++ b/dirty_cat/_feature_augmenter.py
@@ -32,7 +32,7 @@ class FeatureAugmenter(BaseEstimator, TransformerMixin):
         specyfying the transformer objects to be applied.
         table: str
             Name of the table to be joined.
-        column name: str,
+        column: str
             Name of table column to join on.
     main_key : str
         The key column name in the main table on which
diff --git a/dirty_cat/_gap_encoder.py b/dirty_cat/_gap_encoder.py
index 5c5298b..014d6bd 100644
--- a/dirty_cat/_gap_encoder.py
+++ b/dirty_cat/_gap_encoder.py
@@ -777,6 +777,11 @@ class GapEncoder(BaseEstimator, TransformerMixin):
             Fitted :class:`~dirty_cat.GapEncoder` instance (self).
         """
 
+        # Check that n_samples >= n_components
+        if len(X) < self.n_components:
+            raise ValueError(
+                f"n_samples={len(X)} should be >= n_components={self.n_components}. "
+            )
         # Copy parameter rho
         self.rho_ = self.rho
         # If X is a dataframe, store its column names
@@ -875,7 +880,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/_similarity_encoder.py b/dirty_cat/_similarity_encoder.py
index b6d37a9..83c6511 100644
--- a/dirty_cat/_similarity_encoder.py
+++ b/dirty_cat/_similarity_encoder.py
@@ -220,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.
diff --git a/dirty_cat/_super_vectorizer.py b/dirty_cat/_super_vectorizer.py
index 107e1e7..3733916 100644
--- a/dirty_cat/_super_vectorizer.py
+++ b/dirty_cat/_super_vectorizer.py
@@ -382,7 +382,7 @@ class SuperVectorizer(ColumnTransformer):
                 # Some numerical dtypes like Int64 or Float64 only support
                 # pd.NA, so they must be converted to np.float64 before.
                 if pd.api.types.is_numeric_dtype(X[col]):
-                    X.loc[:, col] = X[col].astype(np.float64)
+                    X[col] = X[col].astype(np.float64)
                 X[col].fillna(value=np.nan, inplace=True)
 
         # Convert to the best possible data type
@@ -391,12 +391,12 @@ class SuperVectorizer(ColumnTransformer):
             if not pd.api.types.is_datetime64_any_dtype(X[col]):
                 # we don't want to cast datetime64
                 try:
-                    X.loc[:, col] = pd.to_numeric(X[col], errors="raise")
+                    X[col] = pd.to_numeric(X[col], errors="raise")
                 except (ValueError, TypeError):
                     # Only try to convert to datetime
                     # if the variable isn't numeric.
                     try:
-                        X.loc[:, col] = pd.to_datetime(
+                        X[col] = pd.to_datetime(
                             X[col], errors="raise", infer_datetime_format=True
                         )
                     except (ValueError, TypeError):
@@ -405,7 +405,7 @@ class SuperVectorizer(ColumnTransformer):
             # for earlier versions of sklearn. FIXME: which ?
             if issubclass(X[col].dtype.__class__, ExtensionDtype):
                 try:
-                    X.loc[:, col] = X[col].astype(X[col].dtype.type, errors="ignore")
+                    X[col] = X[col].astype(X[col].dtype.type, errors="ignore")
                 except (TypeError, ValueError):
                     pass
             self.types_.update({col: X[col].dtype})
@@ -419,15 +419,15 @@ class SuperVectorizer(ColumnTransformer):
         Does the same thing as `_auto_cast`, but applies learnt info.
         """
         for col in X.columns:
-            X.loc[:, col] = _replace_false_missing(X[col])
+            X[col] = _replace_false_missing(X[col])
             if _has_missing_values(X[col]):
                 if pd.api.types.is_numeric_dtype(X[col]):
-                    X.loc[:, col] = X[col].astype(np.float64)
+                    X[col] = X[col].astype(np.float64)
                 X[col].fillna(value=np.nan, inplace=True)
         for col in self.imputed_columns_:
-            X.loc[:, col] = _replace_missing_in_cat_col(X[col])
+            X[col] = _replace_missing_in_cat_col(X[col])
         for col, dtype in self.types_.items():
-            X.loc[:, col] = X[col].astype(dtype)
+            X[col] = X[col].astype(dtype)
         return X
 
     def fit_transform(self, X, y=None):
@@ -548,7 +548,7 @@ class SuperVectorizer(ColumnTransformer):
                     for col in X.columns:
                         # Only impute categorical columns
                         if col in categorical_columns:
-                            X.loc[:, col] = _replace_missing_in_cat_col(X[col])
+                            X[col] = _replace_missing_in_cat_col(X[col])
                             self.imputed_columns_.append(col)
 
                 elif self.impute_missing == "auto":
@@ -564,7 +564,7 @@ class SuperVectorizer(ColumnTransformer):
                             for col in cols:
                                 # Only impute categorical columns
                                 if col in categorical_columns:
-                                    X.loc[:, col] = _replace_missing_in_cat_col(X[col])
+                                    X[col] = _replace_missing_in_cat_col(X[col])
                                     self.imputed_columns_.append(col)
 
         # If there was missing values imputation, we cast the DataFrame again,
diff --git a/dirty_cat/datasets/_fetching.py b/dirty_cat/datasets/_fetching.py
index b0eeb77..eeba1a8 100644
--- a/dirty_cat/datasets/_fetching.py
+++ b/dirty_cat/datasets/_fetching.py
@@ -818,5 +818,3 @@ def fetch_world_bank_indicator(
         load_dataframe=load_dataframe,
         data_directory=directory,
     )
-
-
diff --git a/doc/_static/custom.css b/doc/_static/custom.css
index b161abf..63a95cb 100644
--- a/doc/_static/custom.css
+++ b/doc/_static/custom.css
@@ -72,6 +72,15 @@ div.flex-content p {
     margin-right: 1ex;
 }
 
+div.flex-content ul a span.std {
+    float: right;
+   }
+
+/* Avoid an overlap of the float with the line below, eg on narrow screens */
+div.flex-content ul li {
+    clear: both;
+}
+
 span.container-title {
     font-weight: bold;
     color: black;
diff --git a/doc/index.rst b/doc/index.rst
index de61a71..dd6db8f 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -42,11 +42,38 @@ pipeline you can tweak.
 
 * :class:`GapEncoder`, scalable and interpretable, where each encoding
   dimension corresponds to a topic that summarizes substrings captured.
+  :ref:`Example <example_gap_encoder>`
 
 * :class:`SimilarityEncoder`, a simple modification of one-hot encoding
   to capture the strings.
+  :ref:`Example <example_similarity_encoder>`
 
-* :class:`MinHashEncoder`, very scalable
+* :class:`MinHashEncoder`, very scalable.
+  :ref:`Example <example_minhash_encoder>`
+
+.. raw:: html
+
+    </div>
+    <div class="flex-content">
+    <span class="container-title">Joining tables on non-normalized categories</span>
+
+* :func:`fuzzy_join`, approximate matching using morphological similarity.
+  :ref:`Example <example_fuzzy_join>`
+
+* :class:`FeatureAugmenter`, a scikit-learn transformer for joining multiple tables.
+  :ref:`Example <example_feature_augmenter>`
+
+.. raw:: html
+
+    </div>
+    <div class="flex-content">
+    <span class="container-title">Deduplicating dirty categories</span>
+
+:func:`deduplicate`, merging categories of similar morphology (spelling).
+
+.. rst-class:: centered
+
+     :ref:`An example <example_deduplication>`
 
 .. raw:: html
 
diff --git a/examples/01_dirty_categories.py b/examples/01_dirty_categories.py
index d3bcd69..e7ef704 100644
--- a/examples/01_dirty_categories.py
+++ b/examples/01_dirty_categories.py
@@ -153,6 +153,8 @@ import numpy as np
 np.unique(y)
 
 ###############################################################################
+# .. _example_minhash_encoder:
+#
 # We will now experiment with encoders specially made for handling
 # dirty columns:
 
diff --git a/examples/02_investigating_dirty_categories.py b/examples/02_investigating_dirty_categories.py
index f22f64a..96bf957 100644
--- a/examples/02_investigating_dirty_categories.py
+++ b/examples/02_investigating_dirty_categories.py
@@ -67,6 +67,8 @@ values = data[["employee_position_title", "gender"]]
 values.insert(0, "current_annual_salary", employee_salaries.y)
 
 ###############################################################################
+# .. _example_similarity_encoder:
+#
 # String similarity between entries
 # ---------------------------------
 #
@@ -174,6 +176,8 @@ print(X_dirty.head(), end="\n\n")
 print(f"Number of dirty entries = {len(X_dirty)}")
 
 ###############################################################################
+# .. _example_gap_encoder:
+#
 # Encoding dirty job titles
 # .........................
 #
diff --git a/examples/05_fuzzy_joining_and_FeatureAugmenter.py b/examples/05_fuzzy_joining_and_FeatureAugmenter.py
index b222e28..d7eefad 100644
--- a/examples/05_fuzzy_joining_and_FeatureAugmenter.py
+++ b/examples/05_fuzzy_joining_and_FeatureAugmenter.py
@@ -110,6 +110,8 @@ gdppc.sort_values(by="Country Name").tail(7)
 #
 
 ###############################################################################
+# .. _example_fuzzy_join:
+#
 # 1. Joining GDP per capita table
 # ...............................
 #
@@ -373,8 +375,10 @@ print(f"Mean R2 score is {cv_r2_t.mean():.2f} +- {cv_r2_t.std():.2f}")
 # a main table.
 
 #######################################################################
+# .. _example_feature_augmenter:
+#
 # Instantiating the transformer
-# ............................
+# .............................
 
 y = df["Happiness score"]
 #######################################################################
@@ -394,7 +398,7 @@ fa = FeatureAugmenter(
 
 #################################################################
 # 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)
diff --git a/examples/06_deduplication.py b/examples/06_deduplication.py
index 0c3fcf8..02aa10a 100644
--- a/examples/06_deduplication.py
+++ b/examples/06_deduplication.py
@@ -8,8 +8,7 @@ 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*. It is performed by the |dd| function.
 
 Deduplication relies on *unsupervised learning*, to find structure in
 data without providing explicit labels/categories of the data a-priori.
@@ -17,6 +16,9 @@ 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.
+
+
+.. |dd| replace:: :func:`~dirty_cat.deduplicate`
 """
 
 ###############################################################################
@@ -45,13 +47,16 @@ entries_per_medications = [500, 100, 1500]
 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
+    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:
@@ -99,14 +104,16 @@ sns.heatmap(
 )
 
 ###############################################################################
+# .. _example_deduplication:
+#
 # Deduplication: suggest corrections of misspelled names
-# -----------------------------------------------------
+# ------------------------------------------------------
 #
-# The :func:`~dirty_cat.deduplicate` function uses clustering based on
+# The |dd| 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
+# If no fixed number of clusters is given, |dd| tries to set it automatically
 # via the `silhouette score <https://scikit-learn.org/stable/modules/clustering.html#silhouette-coefficient>`_.
 
 from dirty_cat import deduplicate
@@ -131,7 +138,7 @@ plt.ylabel("Counts")
 #
 # 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`.
+# returned by |dd|.
 # It consists of the (potentially) misspelled category names as indices and the
 # (potentially) correct categories as values.
 
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
+===============
diff --git a/setup.cfg b/setup.cfg
index 88b6413..2ec8020 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -23,6 +23,7 @@ project_urls =
     Source = https://github.com/dirty-cat/dirty_cat
 
 [options]
+include_package_data = True
 packages = find:
 install_requires =
     scikit-learn>=0.23.0


Test patch:
diff --git a/dirty_cat/tests/test_gap_encoder.py b/dirty_cat/tests/test_gap_encoder.py
index e39e798..77c2c1f 100644
--- a/dirty_cat/tests/test_gap_encoder.py
+++ b/dirty_cat/tests/test_gap_encoder.py
@@ -197,13 +197,22 @@ def test_missing_values(missing: str) -> None:
         ):
             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)
+
+
+def test_small_sample():
+    """Test that having n_samples < n_components raises an error"""
+    X = np.array([["alice"], ["bob"]])
+    enc = GapEncoder(n_components=3, random_state=42)
+    with pytest.raises(ValueError, match="should be >= n_components"):
+        enc.fit_transform(X)
diff --git a/dirty_cat/tests/test_minhash_encoder.py b/dirty_cat/tests/test_minhash_encoder.py
index 95617c4..0aa504f 100644
--- a/dirty_cat/tests/test_minhash_encoder.py
+++ b/dirty_cat/tests/test_minhash_encoder.py
@@ -193,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)
@@ -217,7 +218,7 @@ def test_backend_respected():
 
     with joblib.parallel_backend("testing") as (ba, n_jobs):
         encoder.fit_transform(X)
-        
+
     assert ba.count > 0