Repository: dirty-cat/dirty_cat
Pull request #530 (2023-03-15 17:03:23)

FEA Improving fuzzy_join: numerical columns and multiple keys
This PR adds new important features to the fuzzy_join:

- Joining on numerical columns is now possible. The distance between columns used is euclidean.

- Many-to-many joins (joining on multiple keys) is also now possible, both for string and numerical keys. For instance, joining on both the string columns ["City", "Country"] or on numerical columns ["latitude", "longitude"] was the motivation for adding this.

GaelVaroquaux: You should merge with master. You have a conflict.
jovan-stojanovic: > It seems to me that the code works only for either all columns numerical or all columns not numerical. We'd like code that works for a mix.
> 
> I would see a structure of the code where all columns get separately injected into a numerical representation (scaled by it's 2 norm), and are then combinered. This approach can be later expanded to account for datetime (we will need to do so at some point)

Thanks, it is now possible to join on mixed types of columns.
I worked along the lines of your idea, so the code structure is the following:
- check if only numerical columns then do numerical join;
- check if only string columns then do joins on strings;
- check if mixed types columns: encode the columns separately based on their type and then create the "full" encoding
array of all columns and find the nearest neighbor.

In this way, it is made easy I think to add new types such as datetime with separate encoding methods.
jovan-stojanovic: I have also an idea on how to join on datetime values, but I think it should be added in a follow-up PR, this one already adds a lot to the function.
jovan-stojanovic: I have one small change left and it's ready.
jovan-stojanovic: Ok, I think this ready to be merged when the tests pass @Vincent-Maladiere @LilianBoulard 

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 2a5defe..dd5a8ff 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,15 +7,24 @@ Release 0.4.1
 
 Major changes
 -------------
+* :func:`fuzzy_join` and :class:`FeatureAugmenter` can now join on numerical columns based on the euclidean distance.
+  :pr:`530` by :user:`Jovan Stojanovic <jovan-stojanovic>`
+
+* :func:`fuzzy_join` and :class:`FeatureAugmenter` can perform many-to-many joins on lists of numerical or string key columns.
+  :pr:`530` by :user:`Jovan Stojanovic <jovan-stojanovic>`
+
 * :func:`GapEncoder.transform` will not continue fitting of the instance anymore.
   It makes functions that depend on it (:func:`~GapEncoder.get_feature_names_out`,
   :func:`~GapEncoder.score`, etc.) deterministic once fitted.
   :pr:`548` by :user:`Lilian Boulard <LilianBoulard>`
+
 * :func:`fuzzy_join` and :class:`FeatureAugmenter` now perform joins on missing values as in `pandas.merge`
   but raises a warning. :pr:`522` and :pr:`529` by :user:`Jovan Stojanovic <jovan-stojanovic>`
+
 * Added :func:`get_ken_table_aliases` and :func:`get_ken_types` for exploring
   KEN embeddings. :pr:`539` by :user:`Lilian Boulard <LilianBoulard>`.
 
+
 Minor changes
 -------------
 * Improvement of date column detection and date format inference in :class:`TableVectorizer`. The
diff --git a/dirty_cat/_feature_augmenter.py b/dirty_cat/_feature_augmenter.py
index 1d4b333..610188e 100644
--- a/dirty_cat/_feature_augmenter.py
+++ b/dirty_cat/_feature_augmenter.py
@@ -2,9 +2,9 @@
 Transformer that allows multiple fuzzy joins to be performed on a table.
 The principle is as follows:
   1. The main table and the key column name are provided at initialisation.
-  2. The auxilliary tables are provided for fitting, and will be joined sequentially
-  when the transform is called.
-It is advised to use hyper-parameter tuning tools such as scikit-learn's
+  2. The auxiliary tables are provided for fitting, and will be joined
+  sequentially when `transform` is called.
+It is advised to use hyperparameter tuning tools such as scikit-learn's
 GridSearchCV to determine the best `match_score` parameter, as this can
 significantly improve your results.
 (see example 'Fuzzy joining dirty tables with the FeatureAugmenter' for an illustration)
@@ -42,6 +42,8 @@ class FeatureAugmenter(BaseEstimator, TransformerMixin):
         In a [0, 1] interval. 1 means that only a perfect match will be
         accepted, and zero means that the closest match will be accepted,
         no matter how distant.
+        For numerical joins, this defines the maximum Euclidean distance
+        between the matches.
     analyzer : typing.Literal["word", "char", "char_wb"], default=`char_wb`
         Analyzer parameter for the CountVectorizer used for the string
         similarities.
diff --git a/dirty_cat/_fuzzy_join.py b/dirty_cat/_fuzzy_join.py
index dfd869b..44ba1b6 100644
--- a/dirty_cat/_fuzzy_join.py
+++ b/dirty_cat/_fuzzy_join.py
@@ -7,32 +7,171 @@ The principle is as follows:
   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
+have greater probability of being matched together. The join is based on
 morphological similarities between strings.
+Joining on numerical columns is also possible based on the Euclidean distance.
 """
 
+import numbers
 import warnings
-from typing import Literal, Tuple, Union
+from collections.abc import Iterable
+from typing import List, Literal, Optional, Tuple, Union
 
 import numpy as np
 import pandas as pd
-from scipy.sparse import vstack
+from scipy.sparse import hstack, vstack
 from sklearn.feature_extraction.text import (
     HashingVectorizer,
     TfidfTransformer,
     _VectorizerMixin,
 )
 from sklearn.neighbors import NearestNeighbors
+from sklearn.preprocessing import StandardScaler
+
+
+def _numeric_encoding(
+    main: pd.DataFrame,
+    main_cols: Union[list, str],
+    aux: pd.DataFrame,
+    aux_cols: Union[list, str],
+) -> tuple:
+    """Encoding numerical columns.
+
+    Parameters
+    ----------
+    main : :class:`~pandas.DataFrame`
+        A table with numerical columns.
+    main_cols : str or list
+        The columns of the main table.
+    aux : :class:`~pandas.DataFrame`
+        Another table with numerical columns.
+    aux_cols : str or list
+        The columns of the aux table.
+
+    Returns
+    -------
+    array-like
+        An array of the encoded columns of the main table.
+    array-like
+        An array of the encoded columns of the aux table.
+    """
+    aux_array = aux[aux_cols].to_numpy()
+    main_array = main[main_cols].to_numpy()
+    # Re-weighting to avoid measure specificity
+    scaler = StandardScaler()
+    scaler.fit(np.vstack((aux_array, main_array)))
+    aux_array = scaler.transform(aux_array)
+    main_array = scaler.transform(main_array)
+    return main_array, aux_array
+
+
+def _string_encoding(
+    main: pd.DataFrame,
+    main_cols: Union[list, str],
+    aux: pd.DataFrame,
+    aux_cols: Union[list, str],
+    analyzer: Literal["word", "char", "char_wb"],
+    ngram_range: Tuple[int, int],
+    encoder: _VectorizerMixin = None,
+) -> tuple:
+    """Encoding string columns.
+
+    Parameters
+    ----------
+    main : :class:`~pandas.DataFrame`
+        A table with string columns.
+    main_cols : str or list
+        The columns of the main table.
+    aux : :class:`~pandas.DataFrame`
+        Another table with string columns.
+    aux_cols : str or list
+        The columns of the aux table.
+    analyzer : {"word", "char", "char_wb"}
+        Analyzer parameter for the HashingVectorizer passed to
+        the encoder and used for the string similarities.
+        See fuzzy_join's docstring for more information.
+    ngram_range : int 2-tuple, optional, default=(2, 4)
+        The lower and upper boundary of the range of n-values for different
+        n-grams used in the string similarity.
+        See fuzzy_join's docstring for more information.
+    encoder: vectorizer instance, optional
+        Encoder parameter for the Vectorizer.
+        See fuzzy_join's docstring for more information.
+
+    Returns
+    -------
+    array-like
+        An array of the encoded columns of the main table.
+    array-like
+        An array of the encoded columns of the aux table.
+    """
+    # Make sure that the column types are string and categorical:
+    main_cols_clean = main[main_cols].astype(str)
+    aux_cols_clean = aux[aux_cols].astype(str)
+
+    if isinstance(main_cols, list) and isinstance(aux_cols, list):
+        first_col, other_cols = main_cols[0], main_cols[1:]
+        main_cols_clean = main_cols_clean[first_col].str.cat(
+            main_cols_clean[other_cols], sep="  "
+        )
+        first_col_aux, other_cols_aux = aux_cols[0], aux_cols[1:]
+        aux_cols_clean = aux_cols_clean[first_col_aux].str.cat(
+            aux_cols_clean[other_cols_aux], sep="  "
+        )
+    all_cats = pd.concat([main_cols_clean, aux_cols_clean], axis=0).unique()
+
+    if encoder is None:
+        encoder = HashingVectorizer(analyzer=analyzer, ngram_range=ngram_range)
+
+    encoder = encoder.fit(all_cats)
+    main_enc = encoder.transform(main_cols_clean)
+    aux_enc = encoder.transform(aux_cols_clean)
+
+    all_enc = vstack((main_enc, aux_enc))
+
+    tfidf = TfidfTransformer().fit(all_enc)
+    main_enc = tfidf.transform(main_enc)
+    aux_enc = tfidf.transform(aux_enc)
+    return main_enc, aux_enc
+
+
+def _nearest_matches(main_array, aux_array) -> Tuple[np.ndarray, np.ndarray]:
+    """Find the closest matches using the nearest neighbors method.
+
+    Parameters
+    ----------
+    main_array : array-like
+        An array of the encoded columns of the main table.
+    aux_array : array-like
+        An array of the encoded columns of the aux table.
+
+    Returns
+    -------
+    np.ndarray
+        Index of the closest matches of the main table in the aux table.
+    np.ndarray
+        Distance between the closest matches, on a scale between 0 and 1.
+    """
+    # Find nearest neighbor using KNN :
+    neigh = NearestNeighbors(n_neighbors=1)
+    neigh.fit(aux_array)
+    distance, neighbors = neigh.kneighbors(main_array, return_distance=True)
+    idx_closest = np.ravel(neighbors)
+    distance = distance / np.max(distance)
+    # Normalizing distance between 0 and 1:
+    matching_score = 1 - (distance / 2)
+    return idx_closest, matching_score
 
 
 def fuzzy_join(
     left: pd.DataFrame,
     right: pd.DataFrame,
     how: Literal["left", "right"] = "left",
-    left_on: Union[str, None] = None,
-    right_on: Union[str, None] = None,
-    on: Union[str, None] = None,
-    encoder: Union[Literal["hashing"], _VectorizerMixin] = None,
+    left_on: Optional[Union[str, List[str], List[int]]] = None,
+    right_on: Optional[Union[str, List[str], List[int]]] = None,
+    on: Union[str, List[str], List[int], None] = None,
+    numerical_match: Literal["string", "number"] = "number",
+    encoder: _VectorizerMixin = None,
     analyzer: Literal["word", "char", "char_wb"] = "char_wb",
     ngram_range: Tuple[int, int] = (2, 4),
     return_score: bool = False,
@@ -56,20 +195,25 @@ def fuzzy_join(
         only "left" and "right" are supported so far, as the fuzzy-join comes
         with its own mechanism to resolve lack of correspondence between
         left and right tables.
-    left_on : str, optional, default=None
-        Name of left table column to join.
-    right_on : str, optional, default=None
-        Name of right table key column to join
-        with left table key column.
-    on : str, optional, default=None
+    left_on : str or list of str, optional
+        Name of left table column(s) to join.
+    right_on : str or list of str, optional
+        Name of right table key column(s) to join
+        with left table key column(s).
+    on : str or list of str or int, optional, default=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.
-    encoder: Union[Literal["hashing"], _VectorizerMixin], default=None,
+    numerical_match: {`string`, `number`}, optional, default='string'
+        For numerical columns, match using the Euclidean distance
+        ("number"). If "string", uses the default n-gram string
+        similarity on the string representation.
+    encoder: _VectorizerMixin, default=None
         Encoder parameter for the Vectorizer.
-        Options: {None, `_VectorizerMixin`}. If None, the
-        encoder will use the `HashingVectorizer`. It is possible to pass a
-        `_VectorizerMixin` custom object to tweak the parameters of the encoder.
+        By default, uses a :class:`~sklearn.feature_extraction.text.HashingVectorizer`.
+        It is possible to pass a vectorizer instance inheriting
+        :class:`~sklearn.feature_extraction.text._VectorizerMixin`
+        to tweak the parameters of the encoder.
     analyzer : {"word", "char", "char_wb"}, optional, default=`char_wb`
         Analyzer parameter for the HashingVectorizer passed to
         the encoder and used for the string similarities.
@@ -89,6 +233,8 @@ def fuzzy_join(
         In a [0, 1] interval. 1 means that only a perfect match will be
         accepted, and zero means that the closest match will be accepted,
         no matter how distant.
+        For numerical joins, this defines the maximum Euclidean distance
+        between the matches.
     drop_unmatched : boolean, default=False
         Remove categories for which a match was not found in the two tables.
     sort : boolean, default=False
@@ -180,103 +326,136 @@ def fuzzy_join(
 
     if encoder is not None:
         if not issubclass(encoder.__class__, _VectorizerMixin):
-            raise ValueError(f"encoder should be a vectorizer object, got {encoder!r}")
+            raise ValueError(
+                "Parameter 'encoder' should be a vectorizer instance or "
+                f"'hashing', got {encoder!r}. "
+            )
 
     if how not in ["left", "right"]:
         raise ValueError(
-            f"how should be either 'left' or 'right', got {how!r}",
+            f"Parameter 'how' should be either 'left' or 'right', got {how!r}. "
         )
 
-    if not isinstance(match_score, (int, float)):
-        raise TypeError("match_score has invalid type, expected integer or float")
+    if numerical_match not in ["string", "number"]:
+        raise ValueError(
+            "Parameter 'numerical_match' should be either 'string' or 'number', "
+            f"got {numerical_match!r}. ",
+        )
 
     for param in [on, left_on, right_on]:
-        if param is not None and not isinstance(param, str):
+        if param is not None and not isinstance(param, Iterable):
             raise TypeError(
-                "Parameter 'left_on', 'right_on' or 'on' has invalid type, expected"
-                " string"
+                "Parameter 'left_on', 'right_on' or 'on' has invalid type,"
+                "expected string or list of column names. "
             )
 
-    # TODO: enable joining on multiple keys as in pandas.merge
-    if on is not None:
-        left_col = on
-        right_col = on
-    elif left_on is not None and right_on is not None:
-        left_col = left_on
-        right_col = right_on
+    if not isinstance(match_score, numbers.Number):
+        raise TypeError(
+            "Parameter 'match_score' has invalid type, expected int or float. "
+        )
+
+    if isinstance(on, str):
+        left_col, right_col = [on], [on]
+    elif isinstance(left_on, str) and isinstance(right_on, str):
+        left_col, right_col = [left_on], [right_on]
+    elif isinstance(on, Iterable):
+        left_col = list(on)
+        right_col = list(on)
+    elif isinstance(left_on, Iterable) and isinstance(right_on, Iterable):
+        left_col = list(left_on)
+        right_col = list(right_on)
     else:
         raise KeyError(
-            "Required parameter missing: either parameter"
-            "'on' or the pair 'left_on', 'right_on' should be specified."
+            "Required parameter missing: either parameter "
+            "'on' or 'left_on' & 'right_on' should be specified."
         )
 
     if how == "left":
-        main_table = left.reset_index(drop=True).copy()
-        aux_table = right.reset_index(drop=True).copy()
-        main_col = left_col
-        aux_col = right_col
-    else:
-        main_table = right.reset_index(drop=True).copy()
-        aux_table = left.reset_index(drop=True).copy()
-        main_col = right_col
-        aux_col = left_col
-
-    # Make sure that the column types are string and categorical:
-    main_col_clean = main_table[main_col].astype(str)
-    aux_col_clean = aux_table[aux_col].astype(str)
+        main_table = left.reset_index(drop=True)
+        aux_table = right.reset_index(drop=True)
+        main_cols = left_col
+        aux_cols = right_col
+    elif how == "right":
+        main_table = right.reset_index(drop=True)
+        aux_table = left.reset_index(drop=True)
+        main_cols = right_col
+        aux_cols = left_col
 
     # Warn if presence of missing values
-    if main_table[main_col].isna().any():
+    if main_table[main_cols].isna().any().any():
         warnings.warn(
-            "You are merging on missing values."
-            " The output correspondence will be random or missing."
-            " To avoid unexpected errors you can drop them.",
+            "You are merging on missing values. "
+            "The output correspondence will be random or missing. "
+            "To avoid unexpected errors you can drop them. ",
             UserWarning,
+            stacklevel=2,
         )
 
-    all_cats = pd.concat([main_col_clean, aux_col_clean], axis=0).unique()
-
-    if encoder is None:
-        enc = HashingVectorizer(analyzer=analyzer, ngram_range=ngram_range)
-    else:
-        enc = encoder
+    main_num_cols = main_table[main_cols].select_dtypes(include="number").columns
+    aux_num_cols = aux_table[aux_cols].select_dtypes(include="number").columns
 
-    enc_cv = enc.fit(all_cats)
-    main_enc = enc_cv.transform(main_col_clean)
-    aux_enc = enc_cv.transform(aux_col_clean)
+    # Check if included columns are numeric:
+    any_numeric = len(main_num_cols) != 0
+    # Check if included columns have mixed types:
+    mixed_types = len(main_cols) > len(main_num_cols)
 
-    all_enc = vstack((main_enc, aux_enc))
-
-    tfidf = TfidfTransformer().fit(all_enc)
-    main_enc = tfidf.transform(main_enc)
-    aux_enc = tfidf.transform(aux_enc)
-
-    # Find nearest neighbor using KNN :
-    neigh = NearestNeighbors(n_neighbors=1)
+    if len(main_cols) == 1 and len(aux_cols) == 1 and any_numeric is False:
+        main_cols = main_cols[0]
+        aux_cols = aux_cols[0]
 
-    neigh.fit(aux_enc)
-    distance, neighbors = neigh.kneighbors(main_enc, return_distance=True)
-    idx_closest = np.ravel(neighbors)
+    if numerical_match in ["number"] and any_numeric and not mixed_types:
+        main_enc, aux_enc = _numeric_encoding(
+            main_table, main_num_cols, aux_table, aux_num_cols
+        )
+        idx_closest, matching_score = _nearest_matches(main_enc, aux_enc)
+    elif numerical_match in ["number"] and any_numeric and mixed_types:
+        main_num_enc, aux_num_enc = _numeric_encoding(
+            main_table, main_num_cols, aux_table, aux_num_cols
+        )
+        main_str_cols = list(set(main_cols) - set(main_num_cols))
+        aux_str_cols = list(set(aux_cols) - set(aux_num_cols))
+        main_str_enc, aux_str_enc = _string_encoding(
+            main_table,
+            main_str_cols,
+            aux_table,
+            aux_str_cols,
+            encoder=encoder,
+            analyzer=analyzer,
+            ngram_range=ngram_range,
+        )
+        main_enc = hstack((main_num_enc, main_str_enc), format="csr")
+        aux_enc = hstack((aux_num_enc, aux_str_enc), format="csr")
+        idx_closest, matching_score = _nearest_matches(main_enc, aux_enc)
+    else:
+        main_enc, aux_enc = _string_encoding(
+            main_table,
+            main_cols,
+            aux_table,
+            aux_cols,
+            encoder=encoder,
+            analyzer=analyzer,
+            ngram_range=ngram_range,
+        )
+        idx_closest, matching_score = _nearest_matches(main_enc, aux_enc)
 
     main_table["fj_idx"] = idx_closest
     aux_table["fj_idx"] = aux_table.index
 
-    norm_distance = 1 - (distance / 2)
     if drop_unmatched:
-        main_table = main_table[match_score <= norm_distance]
-        norm_distance = norm_distance[match_score <= norm_distance]
+        main_table = main_table[match_score <= matching_score]
+        matching_score = matching_score[match_score <= matching_score]
     else:
-        main_table.loc[np.ravel(match_score > norm_distance), "fj_nan"] = 1
+        main_table.loc[np.ravel(match_score > matching_score), "fj_nan"] = 1
 
     if sort:
-        main_table.sort_values(by=[main_col], inplace=True)
+        main_table.sort_values(by=[main_cols], inplace=True)
 
     # To keep order of columns as in pandas.merge (always left table first)
     if how == "left":
         df_joined = pd.merge(
             main_table, aux_table, on="fj_idx", suffixes=suffixes, how=how
         )
-    else:
+    elif how == "right":
         df_joined = pd.merge(
             aux_table, main_table, on="fj_idx", suffixes=suffixes, how=how
         )
@@ -290,8 +469,6 @@ def fuzzy_join(
         df_joined.drop(columns=["fj_idx", "fj_nan"], inplace=True)
 
     if return_score:
-        df_joined = pd.concat(
-            [df_joined, pd.DataFrame(norm_distance, columns=["matching_score"])], axis=1
-        )
+        df_joined["matching_score"] = matching_score
 
     return df_joined


Test patch:
diff --git a/dirty_cat/tests/test_fuzzy_join.py b/dirty_cat/tests/test_fuzzy_join.py
index ba5891d..524a156 100644
--- a/dirty_cat/tests/test_fuzzy_join.py
+++ b/dirty_cat/tests/test_fuzzy_join.py
@@ -1,3 +1,5 @@
+from typing import Literal
+
 import numpy as np
 import pandas as pd
 import pytest
@@ -7,8 +9,10 @@ from dirty_cat import fuzzy_join
 
 
 @pytest.mark.parametrize("analyzer", ["char", "char_wb", "word"])
-def test_fuzzy_join(analyzer):
-    """Testing if fuzzy_join results are as expected."""
+def test_fuzzy_join(analyzer: Literal["char", "char_wb", "word"]):
+    """
+    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]})
@@ -85,11 +89,16 @@ def test_fuzzy_join_dtypes():
 
 
 @pytest.mark.parametrize(
-    "analyzer, on, how",
-    [("a_blabla", ["a"], "left"), (1, 3, "right")],
+    ["analyzer", "on", "how"],
+    [
+        ("a_blabla", True, "left"),
+        (1, 3, "right"),
+    ],
 )
 def test_parameters_error(analyzer, on, how):
-    """Testing if correct errors are raised when wrong parameter values are given."""
+    """
+    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(
@@ -109,6 +118,11 @@ def test_parameters_error(analyzer, on, how):
         match=r"invalid type",
     ):
         fuzzy_join(df1, df2, on="a", match_score="blabla")
+    with pytest.raises(
+        ValueError,
+        match=r"'numerical_match' should be either",
+    ):
+        fuzzy_join(df1, df2, on="a", numerical_match="wrong_name")
 
 
 def test_missing_keys():
@@ -131,16 +145,16 @@ def test_drop_unmatched():
     a = pd.DataFrame({"col1": ["aaaa", "bbb", "ddd dd"], "col2": [1, 2, 3]})
     b = pd.DataFrame({"col1": ["aaa_", "bbb_", "cc ccc"], "col3": [1, 2, 3]})
 
-    c1 = fuzzy_join(a, b, on="col1", match_score=0.5, drop_unmatched=True)
+    c1 = fuzzy_join(a, b, on="col1", match_score=0.6, drop_unmatched=True)
     assert c1.shape == (2, 4)
 
-    c2 = fuzzy_join(a, b, on="col1", match_score=0.5)
+    c2 = fuzzy_join(a, b, on="col1", match_score=0.6)
     assert sum(c2["col3"].isna()) > 0
 
-    c3 = fuzzy_join(a, b, on="col1", how="right", match_score=0.5)
+    c3 = fuzzy_join(a, b, on="col1", how="right", match_score=0.6)
     assert sum(c3["col3"].isna()) > 0
 
-    c4 = fuzzy_join(a, b, on="col1", how="right", match_score=0.5, drop_unmatched=True)
+    c4 = fuzzy_join(a, b, on="col1", how="right", match_score=0.6, drop_unmatched=True)
     assert c4.shape == (2, 4)
 
 
@@ -165,14 +179,16 @@ def test_how_param():
 
     with pytest.raises(
         ValueError,
-        match=r"how should be either 'left' or 'right', got",
+        match=r"Parameter 'how' should be either ",
     ):
         c = fuzzy_join(a, b, how="inner")
 
 
 def test_fuzzy_join_pandas_comparison():
-    """Tests if fuzzy_join's output is as similar as
-    possible with pandas.merge"""
+    """
+    Tests if fuzzy_join's output is as similar as
+    possible with `pandas.merge`.
+    """
     left = pd.DataFrame(
         {
             "key": ["K0", "K1", "K2", "K3"],
@@ -217,10 +233,13 @@ def test_fuzzy_join_pandas_comparison():
 
 
 def test_correct_encoder():
-    """Test that the encoder error checking is working as intended."""
+    """
+    Test that the encoder error checking is working as intended.
+    """
 
     class TestVectorizer(HashingVectorizer):
-        """Implements a custom vectorizer to check if the `encoder`
+        """
+        Implements a custom vectorizer to check if the `encoder`
         parameter uses the passed instance as expected.
         Raises an error when `fit` is called.
         """
@@ -251,10 +270,111 @@ def test_correct_encoder():
     ):
         fuzzy_join(left, right, on="key", how="left", encoder=enc)
 
-    with pytest.raises(ValueError, match=r"encoder should be a vectorizer object"):
+    with pytest.raises(
+        ValueError, match=r"Parameter 'encoder' should be a vectorizer "
+    ):
         fuzzy_join(left, right, on="key", how="left", encoder="awrongencoder")
 
 
+def test_numerical_column():
+    """
+    Testing that fuzzy_join works with numerical columns.
+    """
+
+    left = pd.DataFrame({"str1": ["aa", "a", "bb"], "int": [10, 2, 5]})
+    right = pd.DataFrame(
+        {
+            "str2": ["aa", "bb", "a", "cc", "dd"],
+            "int": [55, 6, 2, 15, 6],
+        }
+    )
+
+    fj_num = fuzzy_join(left, right, on="int", numerical_match="number")
+    n_cols = left.shape[1] + right.shape[1]
+
+    assert fj_num.shape == (len(left), n_cols)
+
+    fj_num2 = fuzzy_join(
+        left, right, on="int", numerical_match="number", return_score=True
+    )
+    assert fj_num2.shape == (len(left), n_cols + 1)
+
+    fj_num3 = fuzzy_join(
+        left,
+        right,
+        on="int",
+        numerical_match="number",
+        match_score=0.8,
+        drop_unmatched=True,
+    )
+    assert fj_num3.shape == (2, n_cols)
+
+
+def test_multiple_keys():
+    """
+    Test fuzzy joining on multiple keys with possibly mixed types.
+    """
+
+    left = pd.DataFrame(
+        {
+            "str1": ["Paris", "Paris", "Paris"],
+            "str2": ["Texas", "France", "Greek God"],
+            "int1": [10, 2, 5],
+            "int2": [103, 250, 532],
+        }
+    )
+    right = pd.DataFrame(
+        {
+            "str_1": ["Paris", "Paris", "Paris", "cc", "dd"],
+            "str_2": ["TX", "FR", "GR Mytho", "cc", "dd"],
+            "int1": [55, 6, 2, 15, 6],
+            "int2": [554, 146, 32, 215, 612],
+        }
+    )
+
+    # On multiple numeric keys
+    fj_num = fuzzy_join(left, right, on=["int1", "int2"], numerical_match="number")
+    assert fj_num.shape == (3, 8)
+
+    # On multiple string keys
+    fj_str = fuzzy_join(
+        left, right, left_on=["str1", "str2"], right_on=["str_1", "str_2"]
+    )
+    assert fj_str.shape == (3, 8)
+
+    # On mixed, numeric and string keys
+    fj_mixed = fuzzy_join(
+        left,
+        right,
+        left_on=["str1", "str2", "int2"],
+        right_on=["str_1", "str_2", "int2"],
+        numerical_match="number",
+    )
+    assert fj_mixed.shape == (3, 8)
+
+
+def test_iterable_input():
+    """
+    Test if iterable input: list, set, dictionary or tuple works.
+    """
+    df1 = pd.DataFrame(
+        {"a": ["ana", "lala", "nana"], "str2": ["Texas", "France", "Greek God"]}
+    )
+    df2 = pd.DataFrame(
+        {"a": ["anna", "lala", "ana", "nnana"], "str_2": ["TX", "FR", "GR Mytho", "dd"]}
+    )
+    assert fuzzy_join(df1, df2, on=["a"]).shape == (3, 4)
+    assert fuzzy_join(df1, df2, on={"a"}).shape == (3, 4)
+    assert fuzzy_join(df1, df2, on="a").shape == (3, 4)
+
+    assert fuzzy_join(
+        df1, df2, left_on=["a", "str2"], right_on={"a", "str_2"}
+    ).shape == (3, 4)
+    assert fuzzy_join(
+        df1, df2, left_on=("a", "str2"), right_on={"a", "str_2"}
+    ).shape == (3, 4)
+
+
 def test_missing_values():
     """
     Test fuzzy joining on missing values.