Repository: dirty-cat/dirty_cat

Issue:
Add tests to ensure fuzzy_join and pandas.merge produce similar results
<!--Provide a brief description of what you'd like to change and why-->
Add tests to ensure fuzzy_join and pandas.merge produce similar results, as much as possible.

It would help the user to know what the output will be when using similar parameter options on fuzzy_join and pandas.merge

Discussion:
jovan-stojanovic: I also added the `sort` parameter, same role as in [pandas.merge](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.merge.html)

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 96fbf2e..6b1ee03 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -18,10 +18,12 @@ Minor changes
 -------------
 * Removed example `Fitting scalable, non-linear models on data with dirty categories`.
 
+* :class:`MinHashEncoder`'s `minhash` method is no longer public.
+
 Bug fixes
 ---------
 
-* :class:`MinHashEncoder` now considers `None` as missing values, rather
+* :class:`MinHashEncoder` now considers `None` and empty strings as missing values, rather
   than raising an error.
 
 Release 0.3.0
diff --git a/dirty_cat/_fuzzy_join.py b/dirty_cat/_fuzzy_join.py
index 67d2b75..04911d9 100644
--- a/dirty_cat/_fuzzy_join.py
+++ b/dirty_cat/_fuzzy_join.py
@@ -33,6 +33,7 @@ def fuzzy_join(
     return_score: bool = False,
     match_score: float = 0,
     drop_unmatched: bool = False,
+    sort: bool = False,
     suffixes: Tuple[str, str] = ("_x", "_y"),
 ) -> pd.DataFrame:
     """
@@ -47,7 +48,7 @@ def fuzzy_join(
     right : pandas.DataFrame
         A table used to merge with.
     how: typing.Literal["left", "right"], default=`left`
-        Type of merge to be performed. Note that unlike pandas' merge, 
+        Type of merge to be performed. Note that unlike pandas' merge,
         only "left" and "right" are supported so far, as the fuzzy-join comes
         with its own mechanism to resolve lack of correspondence between
         left and right tables.
@@ -81,6 +82,10 @@ def fuzzy_join(
         is tolerated.
     drop_unmatched : boolean, default=False
         Remove categories for which a match was not found in the two tables.
+    sort : boolean, default=False
+        Sort the join keys lexicographically in the result DataFrame.
+        If False, the order of the join keys depends on the join type
+        (`how` keyword).
     suffixes : typing.Tuple[str, str], default=('_x', '_y')
         A list of strings indicating the suffix to add when overlaping
         column names.
@@ -94,6 +99,12 @@ def fuzzy_join(
 
     Notes
     -----
+    For regular joins, the output of fuzzy_join is identical
+    to pandas.merge, except that both key columns are returned.
+
+    Joining on indexes and multiple columns is not
+    supported.
+
     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
@@ -166,71 +177,81 @@ def fuzzy_join(
                 " string"
             )
 
-    if how == "left":
-        left_table_clean = left.reset_index(drop=True).copy()
-        right_table_clean = right.reset_index(drop=True).copy()
-    else:
-        # We inverse the process so that the join is performed on the right column
-        left_table_clean = right.reset_index(drop=True).copy()
-        right_table_clean = left.reset_index(drop=True).copy()
-
+    # 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:
-        if how == "left":
-            left_col = left_on
-            right_col = right_on
-        else:
-            left_col = right_on
-            right_col = left_on
+        left_col = left_on
+        right_col = right_on
     else:
         raise KeyError(
             "Required parameter missing: either parameter"
             "'on' or the pair '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
+
     # Drop missing values in key columns
-    left_table_clean.dropna(subset=[left_col], inplace=True)
-    right_table_clean.dropna(subset=[right_col], inplace=True)
+    main_table.dropna(subset=[main_col], inplace=True)
+    aux_table.dropna(subset=[aux_col], inplace=True)
 
     # Make sure that the column types are string and categorical:
-    left_col_clean = left_table_clean[left_col].astype(str)
-    right_col_clean = right_table_clean[right_col].astype(str)
+    main_col_clean = main_table[main_col].astype(str)
+    aux_col_clean = aux_table[aux_col].astype(str)
 
     enc = CountVectorizer(analyzer=analyzer, ngram_range=ngram_range)
 
-    all_cats = pd.concat([left_col_clean, right_col_clean], axis=0).unique()
+    all_cats = pd.concat([main_col_clean, aux_col_clean], axis=0).unique()
 
     enc_cv = enc.fit(all_cats)
-    left_enc = enc_cv.transform(left_col_clean)
-    right_enc = enc_cv.transform(right_col_clean)
+    main_enc = enc_cv.transform(main_col_clean)
+    aux_enc = enc_cv.transform(aux_col_clean)
 
-    all_enc = vstack((left_enc, right_enc))
+    all_enc = vstack((main_enc, aux_enc))
 
     tfidf = TfidfTransformer().fit(all_enc)
-    left_enc = tfidf.transform(left_enc)
-    right_enc = tfidf.transform(right_enc)
+    main_enc = tfidf.transform(main_enc)
+    aux_enc = tfidf.transform(aux_enc)
 
     # Find nearest neighbor using KNN :
     neigh = NearestNeighbors(n_neighbors=1)
-    neigh.fit(right_enc)
-    distance, neighbors = neigh.kneighbors(left_enc, return_distance=True)
+
+    neigh.fit(aux_enc)
+    distance, neighbors = neigh.kneighbors(main_enc, return_distance=True)
     idx_closest = np.ravel(neighbors)
 
-    left_table_clean["fj_idx"] = idx_closest
-    right_table_clean["fj_idx"] = right_table_clean.index
+    main_table["fj_idx"] = idx_closest
+    aux_table["fj_idx"] = aux_table.index
 
     norm_distance = 1 - (distance / 2)
     if drop_unmatched:
-        left_table_clean = left_table_clean[match_score <= norm_distance]
+        main_table = main_table[match_score <= norm_distance]
         norm_distance = norm_distance[match_score <= norm_distance]
     else:
-        left_table_clean.loc[np.ravel(match_score > norm_distance), "fj_nan"] = 1
+        main_table.loc[np.ravel(match_score > norm_distance), "fj_nan"] = 1
 
-    df_joined = pd.merge(
-        left_table_clean, right_table_clean, on="fj_idx", suffixes=suffixes, how="left"
-    )
+    if sort:
+        main_table.sort_values(by=[main_col], 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:
+        df_joined = pd.merge(
+            aux_table, main_table, on="fj_idx", suffixes=suffixes, how=how
+        )
 
     if drop_unmatched:
         df_joined.drop(columns=["fj_idx"], inplace=True)
diff --git a/dirty_cat/_minhash_encoder.py b/dirty_cat/_minhash_encoder.py
index 8c6c86c..5dc0233 100644
--- a/dirty_cat/_minhash_encoder.py
+++ b/dirty_cat/_minhash_encoder.py
@@ -28,6 +28,7 @@ from ._utils import LRUDict, check_input
 
 NoneType = type(None)
 
+
 class MinHashEncoder(BaseEstimator, TransformerMixin):
     """
     Encode string categorical features as a numeric array, minhash method
@@ -89,9 +90,7 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
         """
         return {"X_types": ["categorical"]}
 
-    def minhash(
-        self, string: str, n_components: int, ngram_range: Tuple[int, int]
-    ) -> np.array:
+    def _get_murmur_hash(self, string: str) -> np.array:
         """
         Encode a string using murmur hashing function.
 
@@ -108,10 +107,10 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
 
         Returns
         -------
-        array, shape (n_components, )
+        ndarray of shape (n_components, )
             The encoded string.
         """
-        min_hashes = np.ones(n_components) * np.infty
+        min_hashes = np.ones(self.n_components) * np.infty
         grams = get_unique_ngrams(string, self.ngram_range)
         if len(grams) == 0:
             grams = get_unique_ngrams(" Na ", self.ngram_range)
@@ -119,13 +118,13 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
             hash_array = np.array(
                 [
                     murmurhash3_32("".join(gram), seed=d, positive=True)
-                    for d in range(n_components)
+                    for d in range(self.n_components)
                 ]
             )
             min_hashes = np.minimum(min_hashes, hash_array)
         return min_hashes / (2**32 - 1)
 
-    def get_fast_hash(self, string: str) -> np.array:
+    def _get_fast_hash(self, string: str) -> np.array:
         """
         Encode a string with fast hashing function.
         fast hashing supports both min_hash and minmax_hash encoding.
@@ -137,7 +136,7 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
 
         Returns
         -------
-        np.array of shape (n_components, )
+        ndarray of shape (n_components, )
             The encoded string, using specified encoding scheme.
         """
         if self.minmax_hash:
@@ -172,6 +171,16 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
         MinHashEncoder
             The fitted MinHashEncoder instance.
         """
+        if self.hashing not in ["fast", "murmur"]:
+            raise ValueError(
+                f"Got hashing={self.hashing!r}, "
+                'but expected any of {"fast", "murmur"}. '
+            )
+        if self.handle_missing not in ["error", "zero_impute"]:
+            raise ValueError(
+                f"Got handle_missing={self.handle_missing!r}, but expected "
+                'any of {"error", "zero_impute"}. '
+            )
         self.count = 0
         self.hash_dict_ = LRUDict(capacity=self._capacity)
         return self
@@ -187,7 +196,7 @@ class MinHashEncoder(BaseEstimator, TransformerMixin):
 
         Returns
         -------
-        array, shape (n_samples, n_components)
+        ndarray of shape (n_samples, n_components)
             Transformed input.
         """
         X = check_input(X)
@@ -209,53 +218,42 @@ 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
-                        or x is None):
+                    if (
+                        isinstance(x, float)  # true if x is a missing value
+                        or x is None
+                    ):
+                        is_nan_idx = True
+                    elif len(x) == 0:
                         is_nan_idx = True
                     elif x not in self.hash_dict_:
                         X_out[i, k * self.n_components : counter] = self.hash_dict_[
                             x
-                        ] = self.get_fast_hash(x)
+                        ] = self._get_fast_hash(x)
                     else:
                         X_out[i, k * self.n_components : counter] = self.hash_dict_[x]
                 counter += self.n_components
-        elif self.hashing == "murmur":
+        if self.hashing == "murmur":
             X_out = np.zeros((len(X[:]), self.n_components * X.shape[1]))
             counter = self.n_components
             for k in range(X.shape[1]):
                 X_in = X[:, k].reshape(-1)
                 for i, x in enumerate(X_in):
-                    if isinstance(x, float):
+                    if isinstance(x, float) or x is None:
+                        is_nan_idx = True
+                    elif len(x) == 0:
                         is_nan_idx = True
                     elif x not in self.hash_dict_:
                         X_out[i, k * self.n_components : counter] = self.hash_dict_[
                             x
-                        ] = self.minhash(
-                            x,
-                            n_components=self.n_components,
-                            ngram_range=self.ngram_range,
-                        )
+                        ] = self._get_murmur_hash(x)
                     else:
                         X_out[i, k * self.n_components : counter] = self.hash_dict_[x]
                 counter += self.n_components
-        else:
-            raise ValueError(
-                f"Got hashing={self.hashing}, "
-                'but expected any of {"fast", "murmur"}. '
-            )
 
-        if self.handle_missing == "error":
-            if is_nan_idx:
-                raise ValueError(
-                    "Found missing values in input data; set "
-                    "handle_missing='zero_impute' "
-                    "to encode with missing values. "
-                )
-        elif self.handle_missing == "zero_impute":
-            pass
-        else:
+        if is_nan_idx and self.handle_missing == "error":
             raise ValueError(
-                f"Got handle_missing={self.handle_missing}, but expected "
-                'any of {"error", "zero_impute"}. '
+                "Found missing values in input data; set "
+                "handle_missing='zero_impute' "
+                "to encode with missing values. "
             )
         return X_out
diff --git a/dirty_cat/_string_distances.py b/dirty_cat/_string_distances.py
index 0822421..e17a5dd 100644
--- a/dirty_cat/_string_distances.py
+++ b/dirty_cat/_string_distances.py
@@ -66,8 +66,8 @@ def get_unique_ngrams(string: str, ngram_range: Tuple[int, int]):
     string : str
         The string to split in n-grams.
     ngram_range : tuple (min_n, max_n)
-    The lower and upper boundary of the range of n-values for different
-    n-grams to be extracted. All values of n such that min_n <= n <= max_n.
+        The lower and upper boundary of the range of n-values for different
+        n-grams to be extracted. All values of n such that min_n <= n <= max_n.
 
     Returns
     -------
diff --git a/dirty_cat/datasets/__init__.py b/dirty_cat/datasets/__init__.py
index 0a7bdd1..ff8ccc4 100644
--- a/dirty_cat/datasets/__init__.py
+++ b/dirty_cat/datasets/__init__.py
@@ -1,4 +1,6 @@
 from ._fetching import (
+    DatasetAll,
+    DatasetInfoOnly,
     fetch_drug_directory,
     fetch_employee_salaries,
     fetch_medical_charge,
@@ -11,13 +13,15 @@ from ._fetching import (
 )
 
 __all__ = [
-    "get_data_dir",
+    "DatasetAll",
+    "DatasetInfoOnly",
+    "fetch_drug_directory",
+    "fetch_employee_salaries",
     "fetch_medical_charge",
     "fetch_midwest_survey",
-    "fetch_employee_salaries",
-    "fetch_road_safety",
     "fetch_open_payments",
-    "fetch_drug_directory",
+    "fetch_road_safety",
     "fetch_traffic_violations",
     "fetch_world_bank_indicator",
+    "get_data_dir",
 ]
diff --git a/dirty_cat/datasets/_fetching.py b/dirty_cat/datasets/_fetching.py
index f5f8a3b..d0549b6 100644
--- a/dirty_cat/datasets/_fetching.py
+++ b/dirty_cat/datasets/_fetching.py
@@ -64,16 +64,38 @@ class Features:
 
 @dataclass(unsafe_hash=True)
 class DatasetAll:
+    """
+    Represents a dataset and its information.
+    With this state, the dataset is loaded in memory as a pandas DataFrame
+    (``X`` and ``y``).
+    Additional information such as `path` and `read_csv_kwargs` are provided
+    in case the dataframe has to be read from disk, as such:
+    .. code:: python
+        ds = fetch_employee_salaries(load_dataframe=False)
+        df = pd.read_csv(ds.path, **ds.read_csv_kwargs)
+    """
+
     name: str
     description: str
+    source: str
+    target: str
     X: pd.DataFrame
     y: pd.Series
-    source: str
     path: Path
+    read_csv_kwargs: Dict[str, Any]
 
 
 @dataclass(unsafe_hash=True)
 class DatasetInfoOnly:
+    """
+    Represents a dataset and its information.
+    With this state, the dataset is NOT loaded in memory, but can be read
+    with ``path`` and ``read_csv_kwargs``, as such:
+    .. code:: python
+        ds = fetch_employee_salaries(load_dataframe=False)
+        df = pd.read_csv(ds.path, **ds.read_csv_kwargs)
+    """
+
     name: str
     description: str
     source: str
@@ -431,10 +453,12 @@ def fetch_dataset_as_dataclass(
         dataset = DatasetAll(
             name=dataset_name,
             description=info["description"],
+            source=info["source"],
+            target=target,
             X=X,
             y=y,
-            source=info["source"],
             path=info["path"],
+            read_csv_kwargs=read_csv_kwargs,
         )
     else:
         dataset = DatasetInfoOnly(
@@ -462,9 +486,9 @@ def fetch_employee_salaries(
     https://openml.org/d/42125
 
     Description of the dataset:
-    > Annual salary information including gross pay and overtime pay for all
-    active, permanent employees of Montgomery County, MD paid in calendar
-    year 2016. This information will be published annually each year.
+        Annual salary information including gross pay and overtime pay for all
+        active, permanent employees of Montgomery County, MD paid in calendar
+        year 2016. This information will be published annually each year.
 
     Parameters
     ----------
@@ -513,10 +537,10 @@ def fetch_road_safety(
     https://openml.org/d/42803
 
     Description of the dataset:
-    > Data reported to the police about the circumstances of personal injury
-    road accidents in Great Britain from 1979, and the maker and model
-    information of vehicles involved in the respective accident. This version
-    includes data up to 2015.
+        Data reported to the police about the circumstances of personal injury
+        road accidents in Great Britain from 1979, and the maker and model
+        information of vehicles involved in the respective accident.
+        This version includes data up to 2015.
 
     Returns
     -------
@@ -544,14 +568,14 @@ def fetch_medical_charge(
     https://openml.org/d/42720
 
     Description of the dataset:
-    > The Inpatient Utilization and Payment Public Use File (Inpatient PUF)
-    provides information on inpatient discharges for Medicare fee-for-service
-    beneficiaries. The Inpatient PUF includes information on utilization,
-    payment (total payment and Medicare payment), and hospital-specific charges
-    for the more than 3,000 U.S. hospitals that receive Medicare Inpatient
-    Prospective Payment System (IPPS) payments. The PUF is organized by
-    hospital and Medicare Severity Diagnosis Related Group (MS-DRG) and
-    covers Fiscal Year (FY) 2011 through FY 2016.
+        The Inpatient Utilization and Payment Public Use File (Inpatient PUF)
+        provides information on inpatient discharges for Medicare
+        fee-for-service beneficiaries. The Inpatient PUF includes information
+        on utilization, payment (total payment and Medicare payment), and
+        hospital-specific charges for the more than 3,000 U.S. hospitals that
+        receive Medicare Inpatient Prospective Payment System (IPPS) payments.
+        The PUF is organized by hospital and Medicare Severity Diagnosis
+        Related Group (MS-DRG) and covers Fiscal Year (FY) 2011 through FY 2016.
 
     Returns
     -------
@@ -580,7 +604,7 @@ def fetch_midwest_survey(
     https://openml.org/d/42805
 
     Description of the dataset:
-    > Survey to know if people self-identify as Midwesterners.
+        Survey to know if people self-identify as Midwesterners.
 
     Returns
     -------
@@ -609,8 +633,8 @@ def fetch_open_payments(
     https://openml.org/d/42738
 
     Description of the dataset:
-    > Payments given by healthcare manufacturing companies to medical doctors
-    or hospitals.
+        Payments given by healthcare manufacturing companies to medical doctors
+        or hospitals.
 
     Returns
     -------
@@ -640,10 +664,10 @@ def fetch_traffic_violations(
     https://openml.org/d/42132
 
     Description of the dataset:
-    > This dataset contains traffic violation information from all electronic
-    traffic violations issued in the Montgomery County, MD. Any information
-    that can be used to uniquely identify the vehicle, the vehicle owner or
-    the officer issuing the violation will not be published.
+        This dataset contains traffic violation information from all electronic
+        traffic violations issued in the Montgomery County, MD. Any information
+        that can be used to uniquely identify the vehicle, the vehicle owner or
+        the officer issuing the violation will not be published.
 
     Returns
     -------
@@ -673,8 +697,8 @@ def fetch_drug_directory(
     https://openml.org/d/43044
 
     Description of the dataset:
-    > Product listing data submitted to the U.S. FDA for all unfinished,
-    unapproved drugs.
+        Product listing data submitted to the U.S. FDA for all unfinished,
+        unapproved drugs.
 
     Returns
     -------
diff --git a/doc/index.rst b/doc/index.rst
index 8db5a9a..09c8654 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -153,6 +153,7 @@ Data download
    datasets.fetch_open_payments
    datasets.fetch_road_safety
    datasets.fetch_traffic_violations
+   datasets.fetch_drug_directory
    datasets.fetch_world_bank_indicator
    datasets.get_data_dir
 
diff --git a/doc/unlisted.rst b/doc/unlisted.rst
new file mode 100644
index 0000000..5a71ff5
--- /dev/null
+++ b/doc/unlisted.rst
@@ -0,0 +1,17 @@
+========
+Unlisted
+========
+
+This document is here to trigger documentation generation for internal
+functions and classes which should not be on the index page or anywhere else,
+but still have a generated page.
+
+.. currentmodule:: dirty_cat
+
+.. autosummary::
+   :toctree: generated/
+   :template: class.rst
+   :nosignatures:
+
+   datasets.DatasetAll
+   datasets.DatasetInfoOnly


Test patch:
diff --git a/dirty_cat/datasets/tests/test_fetching.py b/dirty_cat/datasets/tests/test_fetching.py
index 053d09f..01a2b11 100644
--- a/dirty_cat/datasets/tests/test_fetching.py
+++ b/dirty_cat/datasets/tests/test_fetching.py
@@ -329,6 +329,7 @@ def test_import_all_datasets(
         name="Example dataset",
         description="This is a dataset.",
         source="https://www.openml.org/",
+        target="To_predict",
         path=Path("/path/to/file.csv"),
         X=pd.DataFrame([1, 2, 3, 4]),
         y=pd.Series(
@@ -336,6 +337,7 @@ def test_import_all_datasets(
                 5,
             ]
         ),
+        read_csv_kwargs={"a": "b"},
     )
 
     expected_return_value_info_only = _fetching.DatasetInfoOnly(
diff --git a/dirty_cat/tests/test_docstrings.py b/dirty_cat/tests/test_docstrings.py
index 394e603..7177f69 100644
--- a/dirty_cat/tests/test_docstrings.py
+++ b/dirty_cat/tests/test_docstrings.py
@@ -28,8 +28,8 @@ DOCSTRING_TEMP_IGNORE_SET = {
     "dirty_cat._gap_encoder.GapEncoder.transform",
     "dirty_cat._minhash_encoder.MinHashEncoder",
     "dirty_cat._minhash_encoder.MinHashEncoder.fit",
-    "dirty_cat._minhash_encoder.MinHashEncoder.get_fast_hash",
-    "dirty_cat._minhash_encoder.MinHashEncoder.minhash",
+    "dirty_cat._minhash_encoder.MinHashEncoder._get_fast_hash",
+    "dirty_cat._minhash_encoder.MinHashEncoder._get_murmur_hash",
     "dirty_cat._minhash_encoder.MinHashEncoder.transform",
     "dirty_cat._similarity_encoder.SimilarityEncoder",
     "dirty_cat._similarity_encoder.SimilarityEncoder.fit",
diff --git a/dirty_cat/tests/test_fuzzy_join.py b/dirty_cat/tests/test_fuzzy_join.py
index d981ecf..d90a132 100644
--- a/dirty_cat/tests/test_fuzzy_join.py
+++ b/dirty_cat/tests/test_fuzzy_join.py
@@ -49,7 +49,7 @@ def test_fuzzy_join(analyzer):
         return_score=True,
         analyzer=analyzer,
     )
-    pd.testing.assert_frame_equal(df_joined2, df_joined3)
+    assert df_joined2.isin(df_joined3).all().all()
 
     df1["a2"] = 1
 
@@ -117,18 +117,23 @@ def test_missing_keys():
 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]})
-    c = fuzzy_join(a, b, on="col1", match_score=0.5, drop_unmatched=True)
-    assert c.shape == (2, 4)
 
-    a = pd.DataFrame({"col1": ["aaaa", "bbb", "ddd dd"], "col2": [1, 2, 3]})
-    b = pd.DataFrame({"col1": ["aaa_", "bbb_", "cc ccc"], "col3": [1, 2, 3]})
-    c = fuzzy_join(a, b, on="col1", match_score=0.5)
-    assert sum(c["col3"].isna()) > 0
+    c1 = fuzzy_join(a, b, on="col1", match_score=0.5, drop_unmatched=True)
+    assert c1.shape == (2, 4)
+
+    c2 = fuzzy_join(a, b, on="col1", match_score=0.5)
+    assert sum(c2["col3"].isna()) > 0
+
+    c3 = fuzzy_join(a, b, on="col1", how="right", match_score=0.5)
+    assert sum(c3["col3"].isna()) > 0
+
+    c4 = fuzzy_join(a, b, on="col1", how="right", match_score=0.5, drop_unmatched=True)
+    assert c4.shape == (2, 4)
 
 
 def test_how_param():
     """
-    Test correct shape of left and right joins. 
+    Test correct shape of left and right joins.
     Also test if an error is raised when an incorrect parameter value is passed.
     """
     a = pd.DataFrame({"col1": ["aaaa", "bbb", "ddd dd"], "col2": [1, 2, 3]})
@@ -150,3 +155,49 @@ def test_how_param():
         match=r"how should be either 'left' or 'right', got",
     ):
         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"""
+    left = pd.DataFrame(
+        {
+            "key": ["K0", "K1", "K2", "K3"],
+            "A": ["A0", "A1", "A2", "A3"],
+            "B": ["B0", "B1", "B2", "B3"],
+        }
+    )
+
+    right = pd.DataFrame(
+        {
+            "key": ["K0", "K1", "K2", "K3"],
+            "C": ["C0", "C1", "C2", "C3"],
+            "D": ["D0", "D1", "D2", "D3"],
+        }
+    )
+
+    result = pd.merge(left, right, on="key", how="left")
+    result_fj = fuzzy_join(left, right, on="key", how="left")
+
+    result_fj.drop(columns=["key_y"], inplace=True)
+    result_fj.rename(columns={"key_x": "key"}, inplace=True)
+
+    pd.testing.assert_frame_equal(result, result_fj)
+
+    result_r = pd.merge(left, right, on="key", how="right")
+    result_r_fj = fuzzy_join(left, right, on="key", how="right")
+
+    result_r_fj.drop(columns=["key_y"], inplace=True)
+    result_r_fj.rename(columns={"key_x": "key"}, inplace=True)
+
+    pd.testing.assert_frame_equal(result_r, result_r_fj)
+
+    left = left.sample(frac=1, random_state=0)
+    right = right.sample(frac=1, random_state=0)
+    result_s = pd.merge(left, right, on="key", how="left", sort=True)
+    result_s_fj = fuzzy_join(left, right, on="key", how="left", sort=True)
+
+    result_s_fj.drop(columns=["key_y"], inplace=True)
+    result_s_fj.rename(columns={"key_x": "key"}, inplace=True)
+
+    pd.testing.assert_frame_equal(result_s, result_s_fj)
diff --git a/dirty_cat/tests/test_minhash_encoder.py b/dirty_cat/tests/test_minhash_encoder.py
index c640fb0..a416046 100644
--- a/dirty_cat/tests/test_minhash_encoder.py
+++ b/dirty_cat/tests/test_minhash_encoder.py
@@ -1,46 +1,39 @@
 import random
-import time
 from string import ascii_lowercase
 
 import numpy as np
 import pandas as pd
 import pytest
-from sklearn.datasets import fetch_20newsgroups
 
 from dirty_cat import MinHashEncoder
-from dirty_cat.datasets import fetch_employee_salaries
 
 
-def test_MinHashEncoder(n_sample: int = 70) -> None:
-    X_txt = fetch_20newsgroups(subset="train")["data"]
-    X = np.array(X_txt[:n_sample])[:, None]
-
-    for minmax_hash in [True, False]:
-        for hashing in ["fast", "murmur"]:
-            if minmax_hash and hashing == "murmur":
-                pass  # not implemented
-
-            # Test output shape
-            encoder = MinHashEncoder(n_components=50, hashing=hashing)
-            encoder.fit(X)
-            y = encoder.transform(X)
-            assert y.shape == (n_sample, 50), str(y.shape)
-            assert len(set(y[0])) == 50
-
-            # Test same seed return the same output
-            encoder = MinHashEncoder(50, hashing=hashing)
-            encoder.fit(X)
-            y2 = encoder.transform(X)
-            np.testing.assert_array_equal(y, y2)
-
-            # Test min property
-            if not minmax_hash:
-                X_substring = [x[: x.find(" ")] for x in X[:, 0]]
-                X_substring = np.array(X_substring)[:, None]
-                encoder = MinHashEncoder(50, hashing=hashing)
-                encoder.fit(X_substring)
-                y_substring = encoder.transform(X_substring)
-                np.testing.assert_array_less(y - y_substring, 0.0001)
+@pytest.mark.parametrize(
+    "hashing, minmax_hash", [("fast", True), ("fast", False), ("murmur", False)]
+)
+def test_MinHashEncoder(hashing, minmax_hash) -> None:
+    X = np.array(["al ice", "b ob", "bob and alice", "alice and bob"])[:, None]
+    # Test output shape
+    encoder = MinHashEncoder(n_components=2, hashing=hashing)
+    encoder.fit(X)
+    y = encoder.transform(X)
+    assert y.shape == (4, 2), str(y.shape)
+    assert len(set(y[0])) == 2
+
+    # Test that using the same seed returns the same output
+    encoder2 = MinHashEncoder(2, hashing=hashing)
+    encoder2.fit(X)
+    y2 = encoder2.transform(X)
+    np.testing.assert_array_equal(y, y2)
+
+    # Test min property
+    if not minmax_hash:
+        X_substring = [x[: x.find(" ")] for x in X[:, 0]]
+        X_substring = np.array(X_substring)[:, None]
+        encoder3 = MinHashEncoder(2, hashing=hashing)
+        encoder3.fit(X_substring)
+        y_substring = encoder3.transform(X_substring)
+        np.testing.assert_array_less(y - y_substring, 0.001)
 
 
 def test_multiple_columns() -> None:
@@ -79,28 +72,42 @@ def test_input_type() -> None:
     enc.fit_transform(X)
 
 
-def profile_encoder(hashing: str = "fast", minmax_hash: bool = False) -> float:
-    # not a unit test
-    employee_salaries = fetch_employee_salaries()
-    df = employee_salaries.X
-    X = df[["employee_position_title"]]
-    t0 = time.time()
-    enc = MinHashEncoder(n_components=50, hashing=hashing, minmax_hash=minmax_hash)
+@pytest.mark.parametrize(
+    "hashing, minmax_hash", [("fast", True), ("fast", False), ("murmur", False)]
+)
+def test_encoder_params(hashing, minmax_hash) -> None:
+    MAX_LIMIT = 255  # extended ASCII Character set
+    i = 0
+    str_list = []
+    for i in range(100):
+        random_string = "aa"
+        for _ in range(100):
+            random_integer = random.randint(0, MAX_LIMIT)
+            random_string += chr(random_integer)
+            if random_integer < 50:
+                random_string += "  "
+        i += 1
+        str_list += [random_string]
+    X = np.array(str_list).reshape(100, 1)
+    enc = MinHashEncoder(
+        n_components=50, hashing=hashing, minmax_hash=minmax_hash, ngram_range=(3, 3)
+    )
     enc.fit(X)
     y = enc.transform(X)
     assert y.shape == (len(X), 50)
-    eta = time.time() - t0
-    return eta
+    X2 = np.array([["a", "", "c"]]).T
+    y2 = enc.transform(X2)
+    assert y2.shape == (len(X2), 50)
 
 
-@pytest.mark.parametrize(
-    "input_type, missing, hashing",
-    [
-        ["numpy", "error", "fast"],
-        ["pandas", "zero_impute", "murmur"],
-        ["numpy", "zero_impute", "fast"],
-    ],
-)
+input_types = ["numpy", "pandas"]
+missings = ["error", "zero_impute", "aaa"]
+hashings = ["fast", "murmur", "aaa"]
+
+
+@pytest.mark.parametrize("input_type", input_types)
+@pytest.mark.parametrize("missing", missings)
+@pytest.mark.parametrize("hashing", hashings)
 def test_missing_values(input_type: str, missing: str, hashing: str) -> None:
     X = ["Red", np.nan, "green", "blue", "green", "green", "blue", float("nan")]
     n = 3
@@ -114,33 +121,39 @@ def test_missing_values(input_type: str, missing: str, hashing: str) -> None:
     encoder = MinHashEncoder(
         n_components=n, hashing=hashing, minmax_hash=False, handle_missing=missing
     )
-    if missing == "error":
-        encoder.fit(X)
-        if input_type in ["numpy", "pandas"]:
-            with pytest.raises(ValueError, match=r"missing values in input"):
-                encoder.transform(X)
-    elif missing == "zero_impute":
-        encoder.fit(X)
-        y = encoder.transform(X)
-        if input_type == "list":
-            assert np.allclose(y[1], y[-1])
-        else:
+
+    if hashing == "aaa":
+        with pytest.raises(ValueError, match=r"Got hashing="):
+            encoder.fit_transform(X)
+    else:
+        if missing == "error":
+            if input_type in ["numpy", "pandas"]:
+                with pytest.raises(
+                    ValueError, match=r"Found missing values in input data; set"
+                ):
+                    encoder.fit_transform(X)
+        elif missing == "zero_impute":
+            y = encoder.fit_transform(X)
             assert np.array_equal(y[1], z)
             assert np.array_equal(y[-1], z)
-    else:
-        with pytest.raises(ValueError, match=r"expected any of"):
-            encoder.fit_transform(X)
+        else:
+            with pytest.raises(ValueError, match=r"Got handle_missing="):
+                encoder.fit_transform(X)
     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
+    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)
 
+    e = np.array([["a", "b", "", "c"]], dtype=object).T
+    f = enc.fit_transform(e)
+    np.testing.assert_array_equal(f[2], 0)
+
 
 def test_cache_overflow() -> None:
     # Regression test for cache overflow resulting in -1s in encoding
@@ -156,17 +169,3 @@ def test_cache_overflow() -> None:
     y = encoder.fit_transform(raw_data)
 
     assert len(y[y == -1.0]) == 0
-
-
-@pytest.mark.parametrize("idx", range(3))
-def test_min_hash_encoder_hashing_fast_minmax_hash(idx):
-    print(f"{profile_encoder(hashing='fast', minmax_hash=True):.4} seconds")
-
-
-@pytest.mark.parametrize("idx", range(3))
-def test_min_hash_encoder_hashing_fast(idx):
-    print(f"{profile_encoder(hashing='fast'):.4} seconds")
-
-
-def test_min_hash_encoder_mumur():
-    print(f"{profile_encoder(hashing='murmur'):.4} seconds")