Repository: dirty-cat/dirty_cat

Issue:
Contributing guidelines missing clear instructions on branch creation
<!--Describe your proposed enhancement in detail-->
As noted in the title and here: https://github.com/dirty-cat/dirty_cat/pull/368#issuecomment-1254731292
<!--List pages that would be impacted by the enhancement-->

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index f774809..d3a8f13 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,14 +1,32 @@
 .. currentmodule:: dirty_cat
 
 Release 0.4.0
-=============
+==============
 
-Minor changes
+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.
+* **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.
+
+Major changes
 -------------
 
 * Unnecessary API has been made private: everything (files, functions, classes)
   starting with an underscore shouldn't be imported in your code.
 
+Minor changes
+-------------
+
+Bug fixes
+---------
+
+* :class:`MinHashEncoder` now considers `None` as missing values, rather
+  than raising an error.
+
 Release 0.3.0
 =============
 
@@ -55,7 +73,7 @@ Notes
 Release 0.2.2
 =============
 
-Bug-fixes
+Bug fixes
 ---------
 
 * Fixed a bug in the :class:`SuperVectorizer` causing a `FutureWarning`
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 8424861..b5c2d48 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -40,15 +40,15 @@ discover new bugs and limitations.
 If you stumble upon one, please `check if a similar or identical issue already
 exists <https://github.com/dirty-cat/dirty_cat/issues?q=is%3Aissue>`__
 
-- If yes: 
+- If yes:
 
-  - **The issue is still open**: leave the emote :+1: on the original message, 
-    which will let us know there are several users affected by this issue 
-  - **The issue has been closed**: 
+  - **The issue is still open**: leave the emote :+1: on the original message,
+    which will let us know there are several users affected by this issue
+  - **The issue has been closed**:
 
-    - **It has been closed by a merged pull request** (1) update your dirty_cat version, 
+    - **It has been closed by a merged pull request** (1) update your dirty_cat version,
       or (2) the fix has not been released in a version yet
-    - **Otherwise**, there might be a ``wontfix`` label, and / or a reason at the bottom of the conversation 
+    - **Otherwise**, there might be a ``wontfix`` label, and / or a reason at the bottom of the conversation
 - If not, `file a new issue <https://github.com/dirty-cat/dirty_cat/issues/new>`__ (see following section)
 
 How do I submit a (good) bug report?
@@ -100,9 +100,9 @@ kind of enhancement you’re submitting.
 If the enhancement is validated
 '''''''''''''''''''''''''''''''
 
-Let maintainers know whether : 
+Let maintainers know whether :
 
-- **You will write the code and submit a PR**. 
+- **You will write the code and submit a pull request (PR)**.
   Writing the feature yourself is the fastest way to getting it
   implemented in the library, and we’ll help in that process if guidance
   is needed! To go further, refer to the section *Writing your first Pull Request*.
@@ -189,7 +189,7 @@ Implementation
 While writing your implementation, there are a few specific project
 goals to keep in mind:
 
-- Pure Python code - no binary extensions, Cython, etc 
+- Pure Python code - no binary extensions, Cython, etc
 - Make production-friendly code
 
   - Try to target the broadest range of versions (Python and dependencies)
@@ -197,7 +197,7 @@ goals to keep in mind:
   - Make code as backward compatible as possible
 - Prefer performance to readability
 
-  - Optimized code might be hard to read, so 
+  - Optimized code might be hard to read, so
     `please comment it <https://stackoverflow.blog/2021/12/23/best-practices-for-writing-code-comments/>`__
 - Use explicit, borderline verbose variables / function names
 - Public functions / methods / variables / class signatures should be documented
@@ -213,9 +213,16 @@ Submitting your code
 First, you’ll want to fork dirty_cat on Github.
 
 That will enable you to push your commits to a branch *on your fork*.
+It is advised to create a new branch every time you work on a new issue,
+to avoid confusion.
+Use the following command to create a branch:
+
+.. code:: commandline
+
+   git checkout -b branch_name
 
 Next, you can use the Github “Compare & pull request” button to submit
-the PR.
+your branch code as a PR.
 
 Integration
 ^^^^^^^^^^^
diff --git a/benchmarks/fuzzy_join_benchmark.py b/benchmarks/fuzzy_join_benchmark.py
new file mode 100644
index 0000000..0a96dc1
--- /dev/null
+++ b/benchmarks/fuzzy_join_benchmark.py
@@ -0,0 +1,232 @@
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+from itertools import product
+
+from thefuzz.fuzz import partial_ratio, WRatio, ratio
+from thefuzz import process
+from autofj import AutoFJ
+from dirty_cat._fuzzy_join import fuzzy_join
+
+
+def fetch_data(dataset_name):
+    """Fetch datasets from https://github.com/chu-data-lab/AutomaticFuzzyJoin/tree/master/src/autofj/benchmark
+    """
+    repository = "chu-data-lab/AutomaticFuzzyJoin"
+    base_url = f"https://raw.githubusercontent.com/{repository}/master/src/autofj/benchmark/{dataset_name}"  # noqa
+    left = pd.read_csv(f"{base_url}/left.csv")
+    right = pd.read_csv(f"{base_url}/right.csv")
+    gt = pd.read_csv(f"{base_url}/gt.csv")
+    return left, right, gt
+
+
+def thefuzz_merge(df_1, df_2, key1, key2, threshold=90, limit=2, scorer=partial_ratio):
+    """
+    Merging using thefuzz
+
+    Parameters:
+        df_1: the left table to join
+        df_2: the right table to join
+        key1: key column of the left table
+        key2: key column of the right table
+        threshold: how close the matches should be to return a match, based on Levenshtein distance
+        limit: the amount of matches that will get returned, these are sorted high to low
+
+    Return:
+        dataframe with boths keys and matches
+    """
+    s = df_2[key2].tolist()
+
+    m = df_1[key1].apply(lambda x: process.extract(x, s, limit=limit, scorer=scorer))
+    df_1["matches"] = m
+
+    m2 = df_1["matches"].apply(
+        lambda x: ", ".join([i[0] for i in x if i[1] >= threshold])
+    )
+
+    df_1["matches"] = m2
+
+    return df_1[df_1["matches"] != ""]
+
+
+def test_autofj(left, right, gt, target):
+    """Merging using AutomaticFuzzyJoin"""
+    autofj = AutoFJ(precision_target=target, verbose=True)
+    LR_joins = autofj.join(left, right, id_column="id")
+
+    gt_joins = gt[["id_l", "id_r"]].values
+    LR_joins = LR_joins[["id_l", "id_r"]].values
+    p, r, f1 = evaluate(LR_joins, gt_joins)
+    print("Precision:", p, "Recall:", r, "F1:", f1)
+    return LR_joins, gt_joins
+
+
+def evaluate(pred_joins, gt_joins):
+    """Evaluate the performance of fuzzy joins
+
+    Parameters
+    ----------
+    pred_joins: list
+        A list of tuple pairs (id_l, id_r) that are predicted to be matches
+
+    gt_joins:
+        The ground truth matches
+
+    Returns
+    -------
+    precision: float
+        Precision score
+
+    recall: float
+        Recall score
+
+    f1: float
+        F1 score
+    """
+    pred = {(le, ri) for le, ri in pred_joins}
+    gt = {(le, ri) for le, ri in gt_joins}
+
+    tp = pred.intersection(gt)
+    precision = len(tp) / len(pred)
+    recall = len(tp) / len(gt)
+    # print('Precision', precision, 'Recall', recall)
+    if precision > 0 or recall > 0:
+        f1 = 2 * precision * recall / (precision + recall)
+    else:
+        f1 = 0
+    return precision, recall, f1
+
+
+def fuzzy_join_precision_recall(
+    left, right, gt, left_col, right_col, analyzer, ngram_range
+):
+    joined_fj = fuzzy_join(
+        right,
+        left,
+        left_on=left_col,
+        right_on=right_col,
+        analyzer=analyzer,
+        ngram_range=ngram_range,
+        return_score=True,
+    )
+    pr_list = []
+    re_list = []
+    f1_list = []
+    pr, re, f1 = evaluate(
+        list(zip(joined_fj["title_r"], joined_fj["title_l"])),
+        list(zip(gt["title_l"], gt["title_r"])),
+    )
+    pr_list.append(pr)
+    re_list.append(re)
+    f1_list.append(f1)
+    return pr_list, re_list, f1_list
+
+
+def thefuzz_precision_recall(
+    left, right, gt, left_col, right_col, scorer=partial_ratio
+):
+    pr_list = []
+    re_list = []
+    f1_list = []
+    for threshold in range(60, 99, 15):
+        joined = thefuzz_merge(
+            left,
+            right,
+            left_col,
+            right_col,
+            threshold=threshold,
+            limit=1,
+            scorer=scorer,
+        )
+        pr, re, f1 = evaluate(
+            list(zip(joined[left_col], joined["matches"])),
+            list(zip(gt["title_l"], gt["title_r"])),
+        )
+        pr_list.append(pr)
+        re_list.append(re)
+        f1_list.append(f1)
+    return pr_list, re_list, f1_list
+
+
+def autofj_precision_recall(left, right, gt, n_points=20):
+    pr_list = []
+    re_list = []
+    f1_list = []
+    for target in np.linspace(0.6, 1, n_points):
+        autofj = AutoFJ(precision_target=target, verbose=True)
+        LR_joins = autofj.join(left, right, id_column="id")
+        if len(LR_joins) != 0:
+            gt_joins = gt[["id_l", "id_r"]].values
+            LR_joins = LR_joins[["id_l", "id_r"]].values
+            p, r, f1 = evaluate(LR_joins, gt_joins)
+            pr_list.append(p)
+            re_list.append(r)
+            f1_list.append(f1)
+            # print("Precision:", p, "Recall:", r, "F1:", f1)
+    return pr_list, re_list, f1_list
+
+
+def best_precision_recall(pr_list, re_list, n_bins=13):
+    bins = np.digitize(re_list, np.quantile(re_list, np.linspace(0, 1, n_bins)))
+    res_pr = np.zeros(n_bins - 1)
+    res_re = np.zeros(n_bins - 1)
+    for i in range(1, n_bins):
+        mask = bins == i
+        # print("Recall:", re_list[mask], "Precision:", pr_list[mask])
+        if len(pr_list[mask]) > 0:
+            res_pr[i - 1] = np.nanmax(pr_list[mask])
+            res_re[i - 1] = np.nanmean(re_list[mask])
+        else:
+            res_pr[i - 1] = np.nan
+            res_re[i - 1] = np.nan
+    return res_pr, res_re
+
+
+left_1, right_1, gt_1 = fetch_data("Country")
+
+pr_list = []
+re_list = []
+for analyzer, max_n_gram in product(["char", "char_wb"], [3, 4, 5]):
+    if analyzer == "word" and max_n_gram > 2:
+        continue
+    precision, recall, f1 = fuzzy_join_precision_recall(
+        left=left_1,
+        right=right_1,
+        gt=gt_1,
+        left_col="title",
+        right_col="title",
+        analyzer=analyzer,
+        ngram_range=(2, max_n_gram),
+    )
+    pr_list.extend(precision)
+    re_list.extend(recall)
+    # plt.plot(recall, precision,
+    #         label=f"fuzzy_join_{analyzer}_{max_n_gram}_{similarity}")
+
+pr_list, re_list = best_precision_recall(np.array(pr_list), np.array(re_list))
+plt.plot(re_list, pr_list, label="fuzzy_join")
+
+pr_list_fw = []
+re_list_fw = []
+for scorer in partial_ratio, ratio, WRatio:
+    precision_fw, recall_fw, f1_fw = thefuzz_precision_recall(
+        left_1, right_1, gt_1, "title", "title", scorer=scorer
+    )
+    pr_list_fw.extend(precision_fw)
+    re_list_fw.extend(recall_fw)
+
+pr_list_fw, re_list_fw = best_precision_recall(
+    np.array(pr_list_fw), np.array(re_list_fw)
+)
+plt.plot(re_list_fw, pr_list_fw, label="thefuzz")
+# plt.plot(recall_fw, precision_fw, label=f'thefuzz_{scorer.__name__}')
+
+precision_fj, recall_fj, f1_fj = autofj_precision_recall(left_1, right_1, gt_1)
+plt.plot(recall_fj, precision_fj, label="autofj")
+
+plt.xlabel("Recall")
+plt.ylabel("Precision")
+plt.legend()
+plt.title("Best Precision / recall on Country")
+plt.savefig("precision_recall.png")
+# plt.show()
diff --git a/benchmarks/fuzzy_join_full_benchmark.py b/benchmarks/fuzzy_join_full_benchmark.py
new file mode 100644
index 0000000..aaa2fab
--- /dev/null
+++ b/benchmarks/fuzzy_join_full_benchmark.py
@@ -0,0 +1,100 @@
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+from itertools import product
+
+from thefuzz import process
+from autofj import AutoFJ
+from thefuzz.fuzz import partial_ratio, WRatio, ratio
+from fuzzy_join_benchmark import (
+    fetch_data,
+    fuzzy_join_precision_recall,
+    thefuzz_precision_recall,
+    autofj_precision_recall,
+)
+from dirty_cat._fuzzy_join import fuzzy_join
+
+
+datasets = [
+    "Country",
+    "BasketballTeam",
+    "Drug",
+    "Device",
+    "ArtificialSatellite",
+    "Amphibian",
+    "Song",
+    "HistoricBuilding",
+    "Wrestler",
+    "EthnicGroup",
+]
+df = pd.DataFrame()
+for dataset in datasets:
+    left_1, right_1, gt_1 = fetch_data(dataset)
+    for analyser, max_n_gram in product(["word", "char", "char_wb"], [3, 4]):
+        if analyser == "word" and max_n_gram > 2:
+            continue
+        model_name = f"fuzzy_join_{analyser}_{max_n_gram}"
+        precision, recall, f1 = fuzzy_join_precision_recall(
+            right_1,
+            left_1,
+            gt_1,
+            "title",
+            "title",
+            analyzer=analyser,
+            ngram_range=(2, max_n_gram),
+        )
+        n_points = len(precision)
+        df = df.append(
+            pd.DataFrame.from_dict(
+                {
+                    "dataset": [dataset] * n_points,
+                    "model": [model_name] * n_points,
+                    "precision": precision,
+                    "recall": recall,
+                    "f1": f1,
+                },
+                orient="index",
+            ).transpose(),
+            ignore_index=True,
+        )
+
+    for scorer in partial_ratio, ratio, WRatio:
+        model_name = f"thefuzz_{scorer.__name__}"
+        precision_fw, recall_fw, f1_fw = thefuzz_precision_recall(
+            left_1, right_1, gt_1, "title", "title", scorer=scorer
+        )
+        n_points = len(precision_fw)
+        df = df.append(
+            pd.DataFrame.from_dict(
+                {
+                    "dataset": [dataset] * n_points,
+                    "model": [model_name] * n_points,
+                    "precision": precision_fw,
+                    "recall": recall_fw,
+                    "f1": f1_fw,
+                },
+                orient="index",
+            ).transpose(),
+            ignore_index=True,
+        )
+
+    precision_fj, recall_fj, f1_fj = autofj_precision_recall(
+        left_1, right_1, gt_1, n_points=10
+    )
+    model_name = "autofj_default"
+    n_points = len(precision_fj)
+    df = df.append(
+        pd.DataFrame.from_dict(
+            {
+                "dataset": [dataset] * n_points,
+                "model": [model_name] * n_points,
+                "precision": precision_fj,
+                "recall": recall_fj,
+                "f1": f1_fj,
+            },
+            orient="index",
+        ).transpose(),
+        ignore_index=True,
+    )
+
+df.to_csv("full_benchmark_2.csv")
diff --git a/build_tools/circle/build_doc.sh b/build_tools/circle/build_doc.sh
index ce2cd35..1229ff9 100755
--- a/build_tools/circle/build_doc.sh
+++ b/build_tools/circle/build_doc.sh
@@ -117,7 +117,7 @@ conda update --yes --quiet conda
 conda create -n $CONDA_ENV_NAME --yes --quiet python="${PYTHON_VERSION:-*}" \
   numpy="${NUMPY_VERSION:-*}" scipy="${SCIPY_VERSION:-*}" \
   pytest coverage matplotlib="${MATPLOTLIB_VERSION:-*}" sphinx \
-  seaborn pillow cython joblib pandas="${PANDAS_VERSION:-*}"
+  seaborn statsmodels pillow cython joblib pandas="${PANDAS_VERSION:-*}"
 #removed scikit learn from conda since it is installed from master after
 
 source activate testenv
diff --git a/dirty_cat/__init__.py b/dirty_cat/__init__.py
index 6fd3e2a..8e508f9 100644
--- a/dirty_cat/__init__.py
+++ b/dirty_cat/__init__.py
@@ -4,6 +4,7 @@ dirty_cat: Learning on dirty categories.
 from pathlib import Path as _Path
 
 from ._datetime_encoder import DatetimeEncoder
+from ._fuzzy_join import fuzzy_join
 from ._gap_encoder import GapEncoder
 from ._minhash_encoder import MinHashEncoder
 from ._similarity_encoder import SimilarityEncoder
@@ -20,4 +21,5 @@ __all__ = [
     "GapEncoder",
     "DatetimeEncoder",
     "SuperVectorizer",
+    "fuzzy_join",
 ]
diff --git a/dirty_cat/_fuzzy_join.py b/dirty_cat/_fuzzy_join.py
new file mode 100644
index 0000000..1330ba2
--- /dev/null
+++ b/dirty_cat/_fuzzy_join.py
@@ -0,0 +1,278 @@
+"""
+Fuzzy joining tables using string columns.
+The principle is as follows:
+  1. We embed and transform the key string columns using CountVectorizer
+  and TfifdTransformer.
+  2. For each category, we use the nearest neighbor method to find its closest
+  neighbor and establish a match.
+  3. We match the tables using the previous information.
+Categories from the two tables that share many sub-strings (n-grams)
+have greater probability of beeing matched together. The join is based on
+morphological similarities between strings.
+"""
+
+import warnings
+from typing import Literal, Tuple, Union
+
+import numpy as np
+import pandas as pd
+from scipy.sparse import vstack
+from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer
+from sklearn.neighbors import NearestNeighbors
+
+
+def fuzzy_join(
+    left: pd.DataFrame,
+    right: pd.DataFrame,
+    left_on: Union[str, None] = None,
+    right_on: Union[str, None] = None,
+    on: Union[str, None] = None,
+    how: Literal["left", "right", "all"] = "all",
+    return_score: bool = False,
+    analyzer: Literal["word", "char", "char_wb"] = "char_wb",
+    ngram_range: Tuple[int, int] = (2, 4),
+    match_score: float = 0,
+    drop_unmatched=False,
+    suffixes: Tuple[str, str] = ("_l", "_r"),
+) -> pd.DataFrame:
+    """
+    Join two tables categorical string columns based on approximate
+    matching and using morphological similarity.
+
+    Parameters
+    ----------
+
+    left : pandas.DataFrame
+        A table to merge.
+    right : pandas.DataFrame
+        A table used to merge with.
+    left_on : typing.Union[str, None]
+        Name of left table column to join.
+    right_on : typing.Union[str, None]
+        Name of right table key column to join
+        with left table key column.
+    on : typing.Union[str, None]
+        Name of common left and right table join key columns.
+        Must be found in both DataFrames. Use only if `left_on`
+        and `right_on` parameters are not specified.
+    how : typing.Literal['left', 'right', 'all'], default='all'
+        Keep the join key columns from the left, right or
+        all tables.
+    return_score : boolean, default=True
+        Wheter to return matching score based on the distance between
+        nearest matched categories.
+    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.
+    match_score : float, default=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.
+    drop_unmatched : boolean, default=False
+        Remove categories for which a match was not found in the two tables.
+    suffixes : typing.Tuple[str, str], default=('_x', '_y')
+        A list of strings indicating the suffix to add when overlaping
+        column names.
+
+    Returns:
+    --------
+    df_joined: pandas.DataFrame
+        The joined table returned as a DataFrame. If `return_score` is True,
+        another column will be added to the DataFrame containing the
+        matching scores.
+
+    Notes
+    -----
+    When return_score=True, the returned DataFrame gives
+    the distances between closest matches in a [0, 1] interval.
+    0 corresponds to no matching n-grams, while 1 is a
+    perfect match.
+
+    When we use `match_score=0`, the function will be forced to impute the
+    nearest match (of the left table category) across all possible matching
+    options in the right table column.
+
+    When the neighbors are distant, we may use the `match_score` parameter
+    with a value bigger than 0 to define the minimal level of matching
+    score tolerated. If it is not reached, matches will be
+    considered as not found and NaN values will be imputed.
+
+    Examples
+    --------
+    >>> df1 = pd.DataFrame({'a': ['ana', 'lala', 'nana'], 'b': [1, 2, 3]})
+    >>> df2 = pd.DataFrame({'a': ['anna', 'lala', 'ana', 'sana'], 'c': [5, 6, 7, 8]})
+
+    >>> df1
+        a  b
+    0   ana  1
+    1  lala  2
+    2  nana  3
+
+    >>> df2
+        a  c
+    0  anna  5
+    1  lala  6
+    2   ana  7
+    3  sana  8
+
+    To do a simple join based on the nearest match:
+
+    >>> fuzzy_join(df1, df2, on='a')
+        a_l  b   a_r    c
+    0   ana  1   ana   7
+    1  lala  2  lala   6
+    2  nana  3  sana   8
+
+    When we want to accept only a certain match precison,
+    we can use the `match_score` argument:
+
+    >>> fuzzy_join(df1, df2, on='a', match_score=1, return_score=True)
+        a_l  b   a_r    c  matching_score
+    0   ana  1   ana  7.0  1.000000
+    1  lala  2  lala  6.0  1.000000
+    2  nana  3   NaN  NaN  0.532717
+
+    As expected, the category "nana" has no exact match (`match_score=1`).
+
+    """
+
+    warnings.warn("This feature is still experimental.")
+
+    if analyzer not in ["char", "word", "char_wb"]:
+        raise ValueError(
+            f"analyzer should be either 'char', 'word' or 'char_wb', got {analyzer!r}",
+        )
+
+    if how not in ["left", "right", "all"]:
+        raise ValueError(
+            f"how should be either 'left', 'right' or 'all', got {how!r}",
+        )
+
+    if len(suffixes) != 2:
+        raise ValueError(f"Invalid number of suffixes: expected 2, got {len(suffixes)}")
+    lsuffix, rsuffix = suffixes
+
+    for param in [on, left_on, right_on]:
+        if param is not None and not isinstance(param, str):
+            raise ValueError(
+                "Parameter 'left_on', 'right_on' or 'on' has invalid type, expected"
+                " string"
+            )
+
+    left_table_clean = left.reset_index(drop=True).copy()
+    right_table_clean = right.reset_index(drop=True).copy()
+
+    overlap_cols = left_table_clean._info_axis.intersection(
+        right_table_clean._info_axis
+    )
+    if len(overlap_cols) > 0:
+        if suffixes[0] == "" and suffixes[1] == "":
+            raise ValueError(f"Columns overlap but no suffix specified: {overlap_cols}")
+        for i in range(len(overlap_cols)):
+            new_name_l = overlap_cols[i] + lsuffix
+            new_name_r = overlap_cols[i] + rsuffix
+            left_table_clean.rename(columns={overlap_cols[i]: new_name_l}, inplace=True)
+            right_table_clean.rename(
+                columns={overlap_cols[i]: new_name_r}, inplace=True
+            )
+            if left_on is not None and overlap_cols[i] in left_on:
+                left_on = new_name_l
+            if right_on is not None and overlap_cols[i] in right_on:
+                right_on = new_name_r
+
+    if on is not None:
+        left_col = on + lsuffix
+        right_col = on + rsuffix
+    elif left_on is not None and right_on is not None:
+        left_col = left_on
+        right_col = right_on
+
+    # Drop missing values in key columns
+    left_table_clean.dropna(subset=[left_col], inplace=True)
+    right_table_clean.dropna(subset=[right_col], inplace=True)
+
+    # Make sure that the column types are string and categorical:
+    left_table_clean[left_col] = (
+        left_table_clean[left_col].astype(str).astype("category")
+    )
+    right_table_clean[right_col] = (
+        right_table_clean[right_col].astype(str).astype("category")
+    )
+
+    enc = CountVectorizer(analyzer=analyzer, ngram_range=ngram_range)
+
+    all_cats = pd.concat(
+        [left_table_clean[left_col], right_table_clean[right_col]], axis=0
+    )
+
+    enc_cv = enc.fit(all_cats)
+    left_enc = enc_cv.transform(left_table_clean[left_col])
+    right_enc = enc_cv.transform(right_table_clean[right_col])
+
+    all_enc = vstack((left_enc, right_enc))
+
+    tfidf = TfidfTransformer().fit(all_enc)
+    left_enc = tfidf.transform(left_enc)
+    right_enc = tfidf.transform(right_enc)
+
+    # Find nearest neighbor using KNN :
+    neigh = NearestNeighbors(n_neighbors=1)
+    neigh.fit(right_enc)
+    distance, neighbors = neigh.kneighbors(left_enc, return_distance=True)
+    idx_closest = np.ravel(neighbors)
+
+    norm_distance = 1 - (distance / 2)
+
+    left_array = np.array(left_table_clean)
+    right_array = np.array(right_table_clean)
+    joined = np.append(
+        left_array,
+        np.array(
+            [
+                right_array[idx_closest[idr]]
+                if norm_distance[idr] >= match_score
+                else np.tile(np.nan, (right_array.shape[1],))
+                for idr in left_table_clean.index
+            ]
+        ),
+        axis=1,
+    )
+
+    cols = list(left_table_clean.columns) + list(right_table_clean.columns)
+    df_joined = pd.DataFrame(joined, columns=cols).replace(r"^\s*$", np.nan, regex=True)
+
+    duplicate_names = df_joined.columns.duplicated(keep=False)
+    if sum(duplicate_names) > 0:
+        warnings.warn("Column names overlaps. Please set appropriate suffixes.")
+        idx_to_keep = list(np.where(~duplicate_names)[0])
+
+    if return_score:
+        df_joined = pd.concat(
+            [df_joined, pd.DataFrame(norm_distance, columns=["matching_score"])], axis=1
+        )
+    if drop_unmatched:
+        df_joined.dropna(subset=[left_col, right_col], inplace=True)
+    if how == "left":
+        if sum(duplicate_names) > 0:
+            idx_to_keep.append(np.where(duplicate_names)[0][0])
+            idx_to_keep.sort()
+            df_joined = df_joined.iloc[:, idx_to_keep]
+        else:
+            df_joined.drop(columns=[right_col], inplace=True)
+    elif how == "right":
+        if sum(duplicate_names) > 0:
+            idx_to_keep.append(np.where(duplicate_names)[0][1])
+            idx_to_keep.sort()
+            df_joined = df_joined.iloc[:, idx_to_keep]
+        else:
+            df_joined.drop(columns=[left_col], inplace=True)
+    return df_joined
diff --git a/dirty_cat/_minhash_encoder.py b/dirty_cat/_minhash_encoder.py
index 96a9a6e..8c6c86c 100644
--- a/dirty_cat/_minhash_encoder.py
+++ b/dirty_cat/_minhash_encoder.py
@@ -26,6 +26,7 @@ from ._fast_hash import ngram_min_hash
 from ._string_distances import get_unique_ngrams
 from ._utils import LRUDict, check_input
 
+NoneType = type(None)
 
 class MinHashEncoder(BaseEstimator, TransformerMixin):
     """
@@ -208,7 +209,8 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
             for k in range(X.shape[1]):
                 X_in = X[:, k].reshape(-1)
                 for i, x in enumerate(X_in):
-                    if isinstance(x, float):  # true if x is a missing value
+                    if (isinstance(x, float)  # true if x is a missing value
+                        or x is None):
                         is_nan_idx = True
                     elif x not in self.hash_dict_:
                         X_out[i, k * self.n_components : counter] = self.hash_dict_[
diff --git a/dirty_cat/_target_encoder.py b/dirty_cat/_target_encoder.py
index 5c6d229..54854b3 100644
--- a/dirty_cat/_target_encoder.py
+++ b/dirty_cat/_target_encoder.py
@@ -64,6 +64,32 @@ class TargetEncoder(BaseEstimator, TransformerMixin):
     For more details, see Micci-Barreca, 2001: A preprocessing scheme for
     high-cardinality categorical attributes in classification and prediction
     problems.
+
+    Examples
+    --------
+    >>> enc = TargetEncoder(handle_unknown='ignore')
+    >>> X = [['male'], ['Male'], ['Female'], ['male'], ['Female']]
+    >>> y = np.array([1, 2, 3, 4, 5])
+
+    >>> enc.fit(X, y)
+    TargetEncoder(handle_unknown='ignore')
+
+    There encoder has found the following categories:
+
+    >>> enc.categories_
+    [array(['Female', 'Male', 'female', 'male'], dtype='<U6')]
+
+    We look at the encoded categories :
+
+    >>> enc.transform(X)
+    array([[3.44444444],
+        [2.11111111],
+        [3.61538462],
+        [3.44444444],
+        [3.61538462]])
+
+    As expected, they were encoded according to their influence on y.
+
     """
 
     n_features_in_: int
diff --git a/dirty_cat/datasets/__init__.py b/dirty_cat/datasets/__init__.py
index 4944b89..0a7bdd1 100644
--- a/dirty_cat/datasets/__init__.py
+++ b/dirty_cat/datasets/__init__.py
@@ -6,6 +6,7 @@ from ._fetching import (
     fetch_open_payments,
     fetch_road_safety,
     fetch_traffic_violations,
+    fetch_world_bank_indicator,
     get_data_dir,
 )
 
@@ -18,4 +19,5 @@ __all__ = [
     "fetch_open_payments",
     "fetch_drug_directory",
     "fetch_traffic_violations",
+    "fetch_world_bank_indicator",
 ]
diff --git a/dirty_cat/datasets/_fetching.py b/dirty_cat/datasets/_fetching.py
index 5d9ecb1..f5f8a3b 100644
--- a/dirty_cat/datasets/_fetching.py
+++ b/dirty_cat/datasets/_fetching.py
@@ -10,10 +10,13 @@ Scikit-Learn's ``fetch_openml()`` function.
 
 import gzip
 import json
+import urllib.request
 import warnings
 from dataclasses import dataclass
 from pathlib import Path
 from typing import Any, Dict, List, Union
+from urllib.error import URLError
+from zipfile import BadZipFile, ZipFile
 
 import pandas as pd
 from sklearn.datasets import fetch_openml
@@ -162,7 +165,91 @@ def fetch_openml_dataset(
     }
 
 
-def _download_and_write_openml_dataset(dataset_id: int, data_directory: Path) -> None:
+def _fetch_world_bank_data(
+    indicator_id: str,
+    data_directory: Path = get_data_dir(),
+) -> Dict[str, Any]:
+    """
+    Gets a dataset from World Bank open data platform
+    (https://data.worldbank.org/).
+
+    Parameters
+    ----------
+    indicator_id: str
+        The ID of the indicator's dataset to fetch.
+    data_directory: Path
+        Optional. A directory to save the data to.
+        By default, the dirty_cat data directory.
+
+    Returns
+    -------
+    Dict[str, Any]
+        A dictionary containing:
+          - ``description``: str
+              The description of the dataset,
+              as gathered from World Bank data.
+          - ``source``: str
+              The dataset's URL from the World Bank data platform.
+          - ``path``: pathlib.Path
+              The local path leading to the dataset,
+              saved as a CSV file.
+
+    """
+    # See if file available locally :
+    path = f"{data_directory}/{indicator_id}.csv"
+    csv_path = Path(path)
+    url = f"https://api.worldbank.org/v2/en/indicator/{indicator_id}?downloadformat=csv"  # noqa
+    if csv_path.is_file() is True:
+        df = pd.read_csv(csv_path, nrows=0)
+        indicator_name = df.columns[1]
+    else:
+        warnings.warn(
+            f"Could not find the dataset {indicator_id} locally. "
+            "Downloading it from World Bank data; this might take a while... "
+            "If it is interrupted, some files might be invalid/incomplete: "
+            "if on the following run, the fetching raises errors, you can try "
+            f"fixing this issue by deleting the directory {csv_path!s}.",
+            UserWarning,
+            stacklevel=2,
+        )
+        try:
+            filehandle, _ = urllib.request.urlretrieve(url)
+            zip_file_object = ZipFile(filehandle, "r")
+            for name in zip_file_object.namelist():
+                if "Metadata" not in name:
+                    true_file = name
+            file = zip_file_object.open(true_file)
+        except BadZipFile:
+            raise FileNotFoundError(
+                f"Couldn't find csv file, the indicator id {indicator_id} seems invalid."  # noqa
+            )
+        except URLError:
+            raise URLError("No internet connection or the website is down.")  # noqa
+        # Read and modify csv file
+        df = pd.read_csv(file, skiprows=3)
+        indicator_name = df.iloc[0, 2]
+        df[indicator_name] = df.stack().groupby(level=0).last()
+        df = df[df[indicator_name] != indicator_id]
+        df = df[["Country Name", indicator_name]]
+        # Save the file
+        data_directory.mkdir(exist_ok=True, parents=True)
+        csv_path = data_directory.resolve() / (indicator_id + ".csv")
+        df.to_csv(csv_path, index=False)
+    description = (
+        f"This table shows the {indicator_name} World Bank indicator."
+        " It can be used as an input table for fuzzy_join."
+    )
+    return {
+        "dataset_name": indicator_name,
+        "description": description,
+        "source": url,
+        "path": csv_path.resolve(),
+    }
+
+
+def _download_and_write_openml_dataset(
+    dataset_id: int, data_directory: Path
+) -> None:  # noqa
     """
     Downloads a dataset from OpenML,
     taking care of creating the directories.
@@ -312,6 +399,7 @@ def fetch_dataset_as_dataclass(
     target: str,
     read_csv_kwargs: dict,
     load_dataframe: bool,
+    source: str = "openml",
 ) -> Union[DatasetAll, DatasetInfoOnly]:
     """
     Takes a dataset identifier, a target column name,
@@ -320,6 +408,9 @@ def fetch_dataset_as_dataclass(
     If you don't need the dataset to be loaded in memory,
     pass `load_dataframe=False`.
 
+    If you are loading data from the World Bank platform,
+    you need to specify `source='world_bank'`.
+
     Returns
     -------
     DatasetAll
@@ -329,7 +420,10 @@ def fetch_dataset_as_dataclass(
         If `load_dataframe=False`
 
     """
-    info = fetch_openml_dataset(dataset_id)
+    if source == "openml":
+        info = fetch_openml_dataset(dataset_id)
+    if source == "world_bank":
+        info = _fetch_world_bank_data(dataset_id)
     if load_dataframe:
         df = pd.read_csv(info["path"], **read_csv_kwargs)
         y = df[target]
@@ -600,3 +694,33 @@ def fetch_drug_directory(
         },
         load_dataframe=load_dataframe,
     )
+
+
+def fetch_world_bank_indicator(
+    indicator_id: str,
+    load_dataframe: bool = True,
+) -> Union[DatasetAll, DatasetInfoOnly]:
+    """Fetches a dataset of an indicator from the World Bank
+       open data platform.
+
+    Description of the dataset:
+    > The dataset contains two columns: the indicator value and the
+      country names. A list of all available indicators can be found
+      at https://data.worldbank.org/indicator.
+
+    Returns
+    -------
+    DatasetAll
+        If `load_dataframe=True`
+
+    DatasetInfoOnly
+        If `load_dataframe=False`
+    """
+    return fetch_dataset_as_dataclass(
+        dataset_name=f"World Bank indicator {indicator_id}",
+        dataset_id=indicator_id,
+        target=[],
+        read_csv_kwargs={},
+        load_dataframe=load_dataframe,
+        source="world_bank",
+    )
diff --git a/doc/index.rst b/doc/index.rst
index 9b06734..8db5a9a 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -129,6 +129,16 @@ Other encoders
 
    DatetimeEncoder
 
+Joining tables
+---------------
+
+.. autosummary::
+   :toctree: generated/
+   :template: function.rst
+   :nosignatures:
+
+   fuzzy_join
+
 Data download
 -----------------------
 
@@ -143,6 +153,7 @@ Data download
    datasets.fetch_open_payments
    datasets.fetch_road_safety
    datasets.fetch_traffic_violations
+   datasets.fetch_world_bank_indicator
    datasets.get_data_dir
 
 About
@@ -167,4 +178,3 @@ dirty-cat received funding from `project DirtyData
 
    Similarity encoding in also available in Spark ML:
    https://github.com/rakutentech/spark-dirty-cat
-
diff --git a/examples/07_joining_tables_with_FuzzyJoin.py b/examples/07_joining_tables_with_FuzzyJoin.py
new file mode 100755
index 0000000..57e1c72
--- /dev/null
+++ b/examples/07_joining_tables_with_FuzzyJoin.py
@@ -0,0 +1,337 @@
+"""
+Merging dirty tables: fuzzy join
+====================================
+
+Here we show how to combine data from different sources,
+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 :func:`fuzzy_join` function allows us to join
+tables without cleaning the data by taking into account 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.
+
+"""
+
+#######################################################################
+# Data Importing and preprocessing
+# --------------------------------
+#
+# We import the happiness score table first:
+import pandas as pd
+
+df = pd.read_csv(
+    "https://raw.githubusercontent.com/dirty-cat/datasets/master/data/Happiness_report_2022.csv",
+    thousands=",",
+)
+df.drop(df.tail(1).index, inplace=True)
+
+#######################################################################
+# Let's look at the table:
+df.head(3)
+
+##############################################################################
+# This is a table that contains the happiness index of a country along with
+# some of the possible explanatory factors: GDP per capita, Social support,
+# Generosity etc.
+#
+# For more information, read the `World Happiness Report <https://worldhappiness.report/>`_.
+X = df[["Country"]]
+y = df[["Happiness score"]]
+
+#######################################################################
+# We keep the country names in our X table and we create
+# the y table with the happiness score (our prediction target, or
+# exogenous variable).
+
+#############################################################################
+# Additional tables from other sources
+# ------------------------------------
+#
+# Now, we need to include explanatory factors from other sources, to
+# complete our covariates (X table).
+#
+# Interesting tables can be found on `the World Bank open data platform
+# <https://data.worldbank.org/>`_, for which we have a downloading
+# function:
+from dirty_cat.datasets import fetch_world_bank_indicator
+
+#################################################################
+# We extract the table containing GDP per capita by country:
+gdppc = fetch_world_bank_indicator(indicator_id="NY.GDP.PCAP.CD").X
+gdppc.head(3)
+
+#################################################################
+# Then another table, with life expectancy by country:
+life_exp = fetch_world_bank_indicator("SP.DYN.LE00.IN", "life_exp").X
+life_exp.head(3)
+
+#################################################################
+# And a table with legal rights strength by country:
+legal_rights = fetch_world_bank_indicator("IC.LGL.CRED.XQ").X
+legal_rights.head(3)
+
+#######################################################################
+# A correspondance problem
+# ------------------------
+#
+# Alas, the entries for countries do not perfectly match between our
+# original table (X), and those that we downloaded from the worldbank:
+
+X.sort_values(by='Country').tail(7)
+
+#######################################################################
+gdppc.sort_values(by='Country Name').tail(7)
+
+#######################################################################
+# We can see that Yemen is written "Yemen*" on one side, and 
+# "Yemen, Rep." on the other.
+#
+# We also have entries that probably do not have correspondances: "World"
+# on one side, whereas the other table only has country-level data.
+
+#######################################################################
+# Joining tables with imperfect correspondance
+# --------------------------------------------
+#
+# We will now join our initial table, X, with the 3 additional ones that
+# we have extracted.
+#
+
+#################################################
+# 1. Joining GDP per capita table
+# ................................
+#
+# To join them with dirty_cat, we only need to do the following:
+from dirty_cat import fuzzy_join
+
+X1 = fuzzy_join(
+    X,  # our table to join
+    gdppc,  # the table to join with
+    left_on="Country",  # the first join key column
+    right_on="Country Name",  # the second join key column
+    return_score=True,
+)
+
+X1.head(20)
+# We merged the first WB table to our initial one.
+
+#################################################################
+# .. topic:: Note:
+#
+#    We fix the `return_score` parameter to `True` so as to keep the matching
+#    score, that we will use later to show what are the worst matches.
+
+#################################################################
+#
+# We see that our :func:`fuzzy_join` succesfully identified the countries,
+# even though some country names differ between tables.
+#
+# For instance, 'Czechia' is well identified as 'Czech Republic' and
+# 'Luxembourg*' as 'Luxembourg'.
+#
+# .. topic:: Note:
+#
+#    This would all be missed out if we were using other methods such as
+#    `pandas.merge <https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.merge.html>`_,
+#    which can only find exact matches.
+#    In this case, to reach the best result, we would have to 'manually' clean
+#    the data (e.g. remove the * after country name) and look
+#    for matching patterns in every observation.
+#
+# Let's do some more inspection of the merging done.
+
+#################################################################
+# The best way to inspect the matches is to use the following function:
+import numpy as np
+
+
+def print_worst_matches(joined_table, n=5):
+    """Prints n worst matches for inspection."""
+    max_ind = np.argsort(joined_table["matching_score"], axis=0)[:n]
+    max_dist = pd.Series(
+        joined_table["matching_score"][max_ind.ravel()].ravel(), index=max_ind.ravel()
+    )
+    worst_matches = joined_table.iloc[list(max_ind.ravel())]
+    worst_matches = worst_matches.assign(matching_score=max_dist)
+    return worst_matches
+
+
+#################################################################
+# Let's print the four worst matches, which will give
+# us an overview of the situation:
+
+print_worst_matches(X1, n=4)
+
+#################################################################
+# We see that some matches were unsuccesful
+# (e.g 'Palestinian Territories*' and 'Palau'),
+# because there is simply no match in the two tables.
+
+#################################################################
+#
+# In this case, it is better to use the threshold parameter
+# so as to include only precise-enough matches:
+#
+X1 = fuzzy_join(
+    X,
+    gdppc,
+    left_on="Country",
+    right_on="Country Name",
+    match_score=0.35,
+    return_score=True,
+)
+print_worst_matches(X1, n=4)
+
+#################################################################
+# Matches that are not available (or precise enough) are marked as `NaN`.
+# We will remove them, as well as missing or unused information:
+
+mask = X1["GDP per capita (current US$)"].notna()
+X1 = X1[mask]
+y = np.ravel(y[mask])
+
+X1.drop(["matching_score"], axis=1, inplace=True)
+
+#################################################################
+#
+# We can finally plot and look at the link between GDP per capita
+# and happiness:
+import matplotlib.pyplot as plt
+import seaborn as sns
+
+sns.set_context("notebook")
+
+plt.figure(figsize=(4, 3))
+ax = sns.regplot(x=X1["GDP per capita (current US$)"], y=y, lowess=True)
+ax.set_ylabel("Happiness index")
+ax.set_title("Is a higher GDP per capita linked to happiness?")
+plt.tight_layout()
+plt.show()
+
+#################################################
+# It seems that the happiest countries are those
+# having a high GDP per capita.
+# However, unhappy countries do not have only low levels
+# of GDP per capita. We have to search for other patterns.
+
+#################################################
+# 2. Joining life expectancy table
+# ................................
+#
+# Now let's include other information that may be relevant, such as
+# life expectancy table:
+X2 = fuzzy_join(
+    X1,
+    life_exp,
+    left_on="Country",
+    right_on="Country Name",
+    match_score=0.45,
+    how="left",
+)
+
+X2.head(3)
+
+#################################################
+# Let's plot this relation:
+plt.figure(figsize=(4, 3))
+fig = sns.regplot(x=X2["Life expectancy at birth, total (years)"], y=y, lowess=True)
+fig.set_ylabel("Happiness index")
+fig.set_title("Is a higher life expectancy linked to happiness?")
+plt.tight_layout()
+plt.show()
+
+#################################################
+# It seems the answer is yes!
+# Countries with higher life expectancy are also happier.
+
+
+#################################################
+# 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:
+X3 = fuzzy_join(
+    X2,
+    legal_rights,
+    left_on="Country",
+    right_on="Country Name",
+    match_score=0.45,
+    how="left",
+)
+
+X3.head(3)
+
+#################################################
+# Let's take a look at their correspondance in a figure:
+plt.figure(figsize=(4, 3))
+fig = sns.regplot(
+    x=X3["Strength of legal rights index (0=weak to 12=strong)"], y=y, lowess=True
+)
+fig.set_ylabel("Happiness index")
+fig.set_title("Does a country's legal rights strength lead to happiness?")
+plt.tight_layout()
+plt.show()
+
+#################################################################
+# From this plot, it is not clear that this measure of legal strength
+# is linked to happiness.
+
+#################################################################
+# Great! Our joined table has became bigger and full of useful informations.
+# And now we are ready to apply a first machine learning model to it!
+
+###################################################################
+# Prediction model
+# -----------------
+#
+# Let us now define the model that will be used to predict the happiness score:
+from sklearn import __version__ as sklearn_version
+
+if sklearn_version < "1.0":
+    from sklearn.experimental import enable_hist_gradient_boosting
+from sklearn.ensemble import HistGradientBoostingRegressor
+from sklearn.model_selection import KFold
+
+hgdb = HistGradientBoostingRegressor(random_state=0)
+cv = KFold(n_splits=2, shuffle=True, random_state=0)
+
+#################################################################
+# To evaluate our model, we will apply a `4-fold cross-validation`.
+# We evaluate our model using the `R2` score.
+#
+# Let's finally assess the results of our models:
+from sklearn.model_selection import cross_validate
+
+cv_results_t = cross_validate(
+    hgdb, X3.select_dtypes(exclude=object), y, cv=cv, scoring="r2"
+)
+
+cv_r2_t = cv_results_t["test_score"]
+
+print(
+    f"Mean R2 score with {len(X3.columns) - 2} feature columns is"
+    f" {cv_r2_t.mean():.2f} +- {cv_r2_t.std():.2f}"
+)
+
+#################################################################
+# We have a satisfying first result: an R2 of 0.63!
+#
+# Data cleaning varies from dataset to dataset: there are as
+# many ways to clean a table as there are errors. :func:`fuzzy_join`
+# method is generalizable across all datasets.
+#
+# Data transformation is also often very costly in both time and ressources.
+# :func:`fuzzy_join` is fast and easy-to-use.
+#
+# Now up to you, try improving our model by adding information into it and
+# beating our result!


Test patch:
diff --git a/dirty_cat/datasets/tests/test_fetching.py b/dirty_cat/datasets/tests/test_fetching.py
index 8dff87b..053d09f 100644
--- a/dirty_cat/datasets/tests/test_fetching.py
+++ b/dirty_cat/datasets/tests/test_fetching.py
@@ -10,6 +10,7 @@ from pathlib import Path
 from unittest import mock
 from unittest.mock import mock_open
 from urllib.error import URLError
+from zipfile import BadZipFile
 
 import pandas as pd
 import pytest
@@ -26,6 +27,9 @@ from dirty_cat.datasets._fetching import (
     _read_json_from_gz,
 )
 from dirty_cat.datasets._fetching import fetch_openml_dataset as _fetch_openml_dataset
+from dirty_cat.datasets._fetching import (
+    fetch_world_bank_indicator as fetch_world_bank_indicator,
+)
 from dirty_cat.datasets._utils import get_data_dir as _get_data_dir
 
 
@@ -383,3 +387,65 @@ def test_import_all_datasets(
 
         returned_value = _fetching.fetch_drug_directory()
         assert expected_return_value == returned_value
+
+
+def test_fetch_world_bank_indicator():
+    """
+    Tests the ``fetch_world_bank_indicator()``
+    function in a real environment.
+    """
+    test_dataset = {
+        "id": "NY.GDP.PCAP.CD",
+        "desc_start": "This table shows",
+        "url": "https://api.worldbank.org/v2/en/indicator/NY.GDP.PCAP.CD?downloadformat=csv",  # noqa
+        "dataset_columns_count": 2,
+    }
+
+    test_data_dir = get_test_data_dir()
+
+    try:
+        try:
+            # First, we want to purposefully test FileNotFoundError exceptions.
+            with pytest.raises(FileNotFoundError):
+                assert fetch_world_bank_indicator(indicator_id=0)
+                assert fetch_world_bank_indicator(indicator_id=2**32)
+
+            # Valid call
+            returned_info = fetch_world_bank_indicator(indicator_id=test_dataset["id"])
+
+        except BadZipFile:
+            test_id = test_dataset["id"]
+            with pytest.raises(
+                FileNotFoundError,
+                match=(
+                    f"Couldn't find csv file, the indicator id {test_id} seems invalid."
+                ),
+            ):
+                fetch_world_bank_indicator(indicator_id=test_dataset["id"])
+
+        except URLError:
+            with pytest.raises(
+                URLError,
+                match="<urlopen error No internet connection or the website is down.>",
+            ):
+                fetch_world_bank_indicator(indicator_id=test_dataset["id"])
+            warnings.warn(
+                "No internet connection or the website is down, test aborted."
+            )
+            pytest.skip(
+                "Exception: Skipping this test because we encountered an "
+                "issue probably related to an Internet connection problem. "
+            )
+            return
+
+        assert returned_info.description.startswith(test_dataset["desc_start"])
+        assert returned_info.source == test_dataset["url"]
+        assert returned_info.path.is_file()
+
+        dataset: pd.DataFrame = pd.read_csv(returned_info.path)
+
+        assert dataset.columns[0] == "Country Name"
+        assert dataset.shape[1] == test_dataset["dataset_columns_count"]
+
+    finally:
+        shutil.rmtree(path=str(test_data_dir), ignore_errors=True)
diff --git a/dirty_cat/tests/test_fuzzy_join.py b/dirty_cat/tests/test_fuzzy_join.py
new file mode 100644
index 0000000..c4791b1
--- /dev/null
+++ b/dirty_cat/tests/test_fuzzy_join.py
@@ -0,0 +1,170 @@
+import numpy as np
+import pandas as pd
+import pytest
+
+from dirty_cat import fuzzy_join
+
+
+@pytest.mark.parametrize(
+    "analyzer, how",
+    [("char", "left"), ("char_wb", "right"), ("word", "all")],
+)
+def test_fuzzy_join(analyzer, how):
+    """Testing if fuzzy_join results are as expected."""
+
+    df1 = pd.DataFrame({"a1": ["ana", "lala", "nana et sana", np.NaN]})
+    df2 = pd.DataFrame({"a2": ["anna", "lala et nana", "lana", "sana", np.NaN]})
+
+    df_joined = fuzzy_join(
+        left=df1,
+        right=df2,
+        left_on="a1",
+        right_on="a2",
+        match_score=0.5,
+        return_score=True,
+        analyzer=analyzer,
+    )
+
+    n_cols = df1.shape[1] + df2.shape[1] + 1
+
+    assert df_joined.shape == (len(df1.dropna()), n_cols)
+
+    df_joined2 = fuzzy_join(
+        df2,
+        df1,
+        left_on="a2",
+        right_on="a1",
+        match_score=0.45,
+        return_score=True,
+        analyzer=analyzer,
+    )
+    # Joining is always done on the left table and thus takes it shape:
+    assert df_joined2.shape == (len(df2.dropna()), n_cols)
+
+    df_joined3 = fuzzy_join(
+        df2,
+        df1,
+        left_on="a2",
+        right_on="a1",
+        match_score=0.45,
+        return_score=True,
+        analyzer=analyzer,
+    )
+    pd.testing.assert_frame_equal(df_joined2, df_joined3)
+
+    df1["a2"] = 1
+
+    df_how = fuzzy_join(
+        df1,
+        df2,
+        left_on="a1",
+        right_on="a2",
+        analyzer=analyzer,
+        how=how,
+        suffixes=("", "r"),
+    )
+
+    if how == "left":
+        pd.testing.assert_frame_equal(df_how, df1.dropna())
+    if how == "right":
+        assert df_how.shape == (len(df1.dropna()), df1.shape[1])
+
+    df_on = fuzzy_join(df_joined, df1, on="a1", analyzer=analyzer, suffixes=("1", "2"))
+    assert ("a11" and "a12") in df_on.columns
+
+    df2["a1"] = 1
+
+    with pytest.raises(
+        ValueError,
+        match=r"Columns overlap but no suffix",
+    ):
+        fuzzy_join(df1, df2, left_on="a1", right_on="a2", suffixes=("", ""))
+
+    if analyzer == "word":
+        with pytest.warns(
+            UserWarning,
+            match=r"Column names overlaps.",
+        ):
+            fuzzy_join(
+                df1, df2, left_on="a1", right_on="a2", suffixes=("a", "a"), how=how
+            )
+
+    df = fuzzy_join(
+        df1,
+        df2,
+        left_on="a1",
+        right_on="a2",
+        analyzer=analyzer,
+        return_score=True,
+        suffixes=("l", "r"),
+    )
+    assert ("a1l" and "a1r") in df.columns
+
+    df_dropna = fuzzy_join(
+        left=df1,
+        right=df2,
+        left_on="a1",
+        right_on="a2",
+        analyzer=analyzer,
+        match_score=0.5,
+        return_score=True,
+        drop_unmatched=True,
+    )
+    assert df_dropna.shape[0] == df_joined.dropna().shape[0]
+
+
+@pytest.mark.parametrize(
+    "analyzer, how, suffixes, on",
+    [("a_blabla", "k_blabla", ["a", "b", "c"], ["a"]), (1, 34, [1, 2, 3], 3)],
+)
+def test_parameters_error(analyzer, how, suffixes, on):
+    """Testing if correct errors are raised when wrong parameter values are given."""
+    df1 = pd.DataFrame({"a": ["ana", "lala", "nana"], "b": [1, 2, 3]})
+    df2 = pd.DataFrame({"a": ["anna", "lala", "ana", "sana"], "c": [5, 6, 7, 8]})
+    with pytest.raises(
+        ValueError,
+        match=(
+            f"analyzer should be either 'char', 'word' or 'char_wb', got {analyzer!r}"
+        ),
+    ):
+        fuzzy_join(df1, df2, on="a", analyzer=analyzer)
+    with pytest.raises(
+        ValueError,
+        match=f"how should be either 'left', 'right' or 'all', got {how!r}",
+    ):
+        fuzzy_join(df1, df2, on="a", how=how)
+    with pytest.raises(
+        ValueError, match="Invalid number of suffixes: expected 2, got 3"
+    ):
+        fuzzy_join(df1, df2, on="a", suffixes=suffixes)
+    with pytest.raises(
+        ValueError,
+        match=(
+            "Parameter 'left_on', 'right_on' or 'on' has invalid type, expected string"
+        ),
+    ):
+        fuzzy_join(df1, df2, on=on)
+
+
+def test_duplicate_columns_drop():
+    df1 = pd.DataFrame(
+        {"a1": ["ana", "lala", "nana et sana", np.NaN], "b": [14, 13, 51, np.NaN]}
+    )
+    df2 = pd.DataFrame(
+        {
+            "a2": ["anna", "lala et nana", "lana", "sana", np.NaN],
+            "b": [1, 3, 6, 7, np.NaN],
+        }
+    )
+
+    for h in ["left", "right"]:
+        df_joined = fuzzy_join(
+            left=df1,
+            right=df2,
+            left_on="a1",
+            right_on="a2",
+            analyzer="char",
+            suffixes=("1", "1"),
+            how=h,
+        )
+        pd.testing.assert_frame_equal(df_joined, df_joined.dropna())
diff --git a/dirty_cat/tests/test_minhash_encoder.py b/dirty_cat/tests/test_minhash_encoder.py
index b55f56d..c640fb0 100644
--- a/dirty_cat/tests/test_minhash_encoder.py
+++ b/dirty_cat/tests/test_minhash_encoder.py
@@ -133,6 +133,15 @@ def test_missing_values(input_type: str, missing: str, hashing: str) -> None:
     return
 
 
+def test_missing_values_none():
+    # Test that "None" is also understood as a missing value
+    a = np.array([['a', 'b', None, 'c']], dtype=object).T
+
+    enc = MinHashEncoder()
+    d = enc.fit_transform(a)
+    np.testing.assert_array_equal(d[2], 0)
+
+
 def test_cache_overflow() -> None:
     # Regression test for cache overflow resulting in -1s in encoding
     def get_random_string(length):