From a23a2e0a337b0e5f680e6ec53f010cb03b1522db Mon Sep 17 00:00:00 2001 From: Patrick Hoefler <61934744+phofl@users.noreply.github.com> Date: Sat, 23 Mar 2024 21:12:26 -0500 Subject: [PATCH 1/4] ENH: Add leftsemi merge --- asv_bench/benchmarks/join_merge.py | 6 +++ doc/source/user_guide/merging.rst | 1 + doc/source/whatsnew/v3.0.0.rst | 16 ++++-- pandas/_libs/hashtable.pyx | 3 ++ pandas/_libs/hashtable_class_helper.pxi.in | 27 ++++++++++ pandas/_typing.py | 2 +- pandas/core/frame.py | 7 ++- pandas/core/reshape/merge.py | 59 +++++++++++++++++++--- pandas/tests/reshape/merge/test_semi.py | 57 +++++++++++++++++++++ 9 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 pandas/tests/reshape/merge/test_semi.py diff --git a/asv_bench/benchmarks/join_merge.py b/asv_bench/benchmarks/join_merge.py index a6c6990892d38..d0ac3ff1bbe45 100644 --- a/asv_bench/benchmarks/join_merge.py +++ b/asv_bench/benchmarks/join_merge.py @@ -272,6 +272,9 @@ def time_merge_dataframe_empty_left(self, sort): def time_merge_dataframes_cross(self, sort): merge(self.left.loc[:2000], self.right.loc[:2000], how="cross", sort=sort) + def time_merge_semi(self, sort): + merge(self.df, self.df2, on="key1", how="leftsemi") + class MergeEA: params = [ @@ -380,6 +383,9 @@ def setup(self, units, tz, monotonic): def time_merge(self, units, tz, monotonic): merge(self.left, self.right) + def time_merge_semi(self, units, tz, monotonic): + merge(self.left, self.right, how="leftsemi") + class MergeCategoricals: def setup(self): diff --git a/doc/source/user_guide/merging.rst b/doc/source/user_guide/merging.rst index 1edf3908936db..f7e7d1dd24317 100644 --- a/doc/source/user_guide/merging.rst +++ b/doc/source/user_guide/merging.rst @@ -407,6 +407,7 @@ either the left or right tables, the values in the joined table will be ``right``, ``RIGHT OUTER JOIN``, Use keys from right frame only ``outer``, ``FULL OUTER JOIN``, Use union of keys from both frames ``inner``, ``INNER JOIN``, Use intersection of keys from both frames + ``leftsemi``, ``SEMIJOIN``, Filter rows on left based on occurrences in right. ``cross``, ``CROSS JOIN``, Create the cartesian product of rows of both frames .. ipython:: python diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index f748f6e23e003..6e7cd0ac500f8 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -14,10 +14,20 @@ including other versions of pandas. Enhancements ~~~~~~~~~~~~ -.. _whatsnew_300.enhancements.enhancement1: +.. _whatsnew_300.enhancements.semi_merge: -enhancement1 -^^^^^^^^^^^^ +New merge method ``leftsemi`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A new merge method ``leftsemi`` has been added to :func:`merge` and +:meth:`DataFrame.merge` that returns only the rows from the left DataFrame that have +a match in the right DataFrame. This is equivalent to a SQL ``LEFT SEMI JOIN``. (:issue:`42784`) + +.. ipython:: python + + df1 = pd.DataFrame({"key": ["A", "B", "C"], "value": [1, 2, 3]}) + df2 = pd.DataFrame({"key": ["A", "B"], "value": [1, 2]}) + df1.merge(df2, how="leftsemi") .. _whatsnew_300.enhancements.enhancement2: diff --git a/pandas/_libs/hashtable.pyx b/pandas/_libs/hashtable.pyx index 97fae1d6480ce..1ac01e2ce7ae4 100644 --- a/pandas/_libs/hashtable.pyx +++ b/pandas/_libs/hashtable.pyx @@ -123,3 +123,6 @@ cdef class ObjectFactorizer(Factorizer): self.count, na_sentinel, na_value) self.count = len(self.uniques) return labels + + def hash_inner_join(self, values, mask=None): + return self.table.hash_inner_join(values, mask) diff --git a/pandas/_libs/hashtable_class_helper.pxi.in b/pandas/_libs/hashtable_class_helper.pxi.in index e3a9102fec395..205c58c940b27 100644 --- a/pandas/_libs/hashtable_class_helper.pxi.in +++ b/pandas/_libs/hashtable_class_helper.pxi.in @@ -1385,6 +1385,33 @@ cdef class PyObjectHashTable(HashTable): k = kh_put_pymap(self.table, val, &ret) self.table.vals[k] = i + @cython.wraparound(False) + @cython.boundscheck(False) + def hash_inner_join(self, ndarray[object] values, object mask = None) -> tuple[ndarray, ndarray]: + cdef: + Py_ssize_t i, n = len(values) + object val + khiter_t k + Int64Vector locs = Int64Vector() + Int64Vector self_locs = Int64Vector() + Int64VectorData *l + Int64VectorData *sl + # mask not implemented + + l = &locs.data + sl = &self_locs.data + + for i in range(n): + val = values[i] + hash(val) + + k = kh_get_pymap(self.table, val) + if k != self.table.n_buckets: + append_data_int64(l, i) + append_data_int64(sl, self.table.vals[k]) + + return self_locs.to_array(), locs.to_array() + def lookup(self, ndarray[object] values, object mask = None) -> ndarray: # -> np.ndarray[np.intp] # mask not yet implemented diff --git a/pandas/_typing.py b/pandas/_typing.py index f868a92554b39..781240e62a552 100644 --- a/pandas/_typing.py +++ b/pandas/_typing.py @@ -447,7 +447,7 @@ def closed(self) -> bool: AnyAll = Literal["any", "all"] # merge -MergeHow = Literal["left", "right", "inner", "outer", "cross"] +MergeHow = Literal["left", "right", "inner", "outer", "cross", "leftsemi"] MergeValidate = Literal[ "one_to_one", "1:1", diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 5d10a5541f556..bcf1e3f58e720 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -315,7 +315,7 @@ ----------%s right : DataFrame or named Series Object to merge with. -how : {'left', 'right', 'outer', 'inner', 'cross'}, default 'inner' +how : {'left', 'right', 'outer', 'inner', 'leftsemi', 'cross'}, default 'inner' Type of merge to be performed. * left: use only keys from left frame, similar to a SQL left outer join; @@ -326,6 +326,11 @@ join; sort keys lexicographically. * inner: use intersection of keys from both frames, similar to a SQL inner join; preserve the order of the left keys. + * leftsemi: Filter for rows in the left that have a match on the right; + preserve the order of the left keys. Doesn't support `left_index`, `right_index`, + `indicator` or `validate`. + + .. versionadded:: 3.0 * cross: creates the cartesian product from both frames, preserves the order of the left keys. on : label or list diff --git a/pandas/core/reshape/merge.py b/pandas/core/reshape/merge.py index 2cd065d03ff53..54f5b6d90c398 100644 --- a/pandas/core/reshape/merge.py +++ b/pandas/core/reshape/merge.py @@ -166,7 +166,8 @@ def merge( validate=validate, ) else: - op = _MergeOperation( + klass = _MergeOperation if how != "leftsemi" else _SemiMergeOperation + op = klass( left_df, right_df, how=how, @@ -817,7 +818,6 @@ def _validate_tolerance(self, left_join_keys: list[ArrayLike]) -> None: # Overridden by AsOfMerge pass - @final def _reindex_and_concat( self, join_index: Index, @@ -945,7 +945,6 @@ def _indicator_post_merge(self, result: DataFrame) -> DataFrame: result = result.drop(labels=["_left_indicator", "_right_indicator"], axis=1) return result - @final def _maybe_restore_index_levels(self, result: DataFrame) -> None: """ Restore index levels specified as `on` parameters @@ -989,7 +988,6 @@ def _maybe_restore_index_levels(self, result: DataFrame) -> None: if names_to_restore: result.set_index(names_to_restore, inplace=True) - @final def _maybe_add_join_keys( self, result: DataFrame, @@ -1740,7 +1738,8 @@ def get_join_indexers( right = Index(rkey) if ( - left.is_monotonic_increasing + how != "leftsemi" + and left.is_monotonic_increasing and right.is_monotonic_increasing and (left.is_unique or right.is_unique) ): @@ -1883,6 +1882,48 @@ def _convert_to_multiindex(index: Index) -> MultiIndex: return tuple(join_levels), tuple(join_codes), tuple(join_names) +class _SemiMergeOperation(_MergeOperation): + def __init__(self, *args, **kwargs): + if kwargs.get("validate", None): + raise NotImplementedError("validate is not supported for semi-join.") + + super().__init__(*args, **kwargs) + if self.left_index or self.right_index: + raise NotImplementedError( + "left_index or right_index are not supported for semi-join." + ) + elif self.indicator: + raise NotImplementedError("indicator is not supported for semi-join.") + elif self.sort: + raise NotImplementedError( + "sort is not supported for semi-join. Sort your DataFrame afterwards." + ) + + def _maybe_add_join_keys( + self, + result: DataFrame, + left_indexer: npt.NDArray[np.intp] | None, + right_indexer: npt.NDArray[np.intp] | None, + ) -> None: + return + + def _maybe_restore_index_levels(self, result: DataFrame) -> None: + return + + def _reindex_and_concat( + self, + join_index: Index, + left_indexer: npt.NDArray[np.intp] | None, + right_indexer: npt.NDArray[np.intp] | None, + ) -> DataFrame: + left = self.left[:] + + if left_indexer is not None and not is_range_indexer(left_indexer, len(left)): + lmgr = left._mgr.take(left_indexer, axis=1, verify=False) + left = left._constructor_from_mgr(lmgr, axes=lmgr.axes) + return left + + class _OrderedMerge(_MergeOperation): _merge_type = "ordered_merge" @@ -2470,7 +2511,7 @@ def _factorize_keys( lk = ensure_int64(lk.codes) rk = ensure_int64(rk.codes) - elif isinstance(lk, ExtensionArray) and lk.dtype == rk.dtype: + elif how != "leftsemi" and isinstance(lk, ExtensionArray) and lk.dtype == rk.dtype: if (isinstance(lk.dtype, ArrowDtype) and is_string_dtype(lk.dtype)) or ( isinstance(lk.dtype, StringDtype) and lk.dtype.storage in ["pyarrow", "pyarrow_numpy"] @@ -2560,7 +2601,7 @@ def _factorize_keys( lk_data, rk_data = lk, rk # type: ignore[assignment] lk_mask, rk_mask = None, None - hash_join_available = how == "inner" and not sort and lk.dtype.kind in "iufb" + hash_join_available = how == "inner" and not sort if hash_join_available: rlab = rizer.factorize(rk_data, mask=rk_mask) if rizer.get_count() == len(rlab): @@ -2568,6 +2609,10 @@ def _factorize_keys( return lidx, ridx, -1 else: llab = rizer.factorize(lk_data, mask=lk_mask) + elif how == "leftsemi": + # populate hashtable for right and then do a hash join + rizer.factorize(rk_data, mask=rk_mask) + return rizer.hash_inner_join(lk_data, lk_mask)[1], None, -1 else: llab = rizer.factorize(lk_data, mask=lk_mask) rlab = rizer.factorize(rk_data, mask=rk_mask) diff --git a/pandas/tests/reshape/merge/test_semi.py b/pandas/tests/reshape/merge/test_semi.py new file mode 100644 index 0000000000000..92b265fd75d13 --- /dev/null +++ b/pandas/tests/reshape/merge/test_semi.py @@ -0,0 +1,57 @@ +import pytest + +import pandas.util._test_decorators as td + +import pandas as pd +import pandas._testing as tm + + +@pytest.mark.parametrize( + "vals_left, vals_right", + [ + ([1, 2, 3], [1, 2]), + (["a", "b", "c"], ["a", "b"]), + pytest.param( + pd.Series(["a", "b", "c"], dtype="string[pyarrow]"), + pd.Series(["a", "b"], dtype="string[pyarrow]"), + marks=td.skip_if_no("pyarrow"), + ), + ], +) +def test_leftsemi(vals_left, vals_right): + left = pd.DataFrame({"a": vals_left, "b": [1, 2, 3]}) + right = pd.DataFrame({"a": vals_right, "c": 1}) + expected = pd.DataFrame({"a": vals_right, "b": [1, 2]}) + result = left.merge(right, how="leftsemi") + tm.assert_frame_equal(result, expected) + + right = pd.DataFrame({"d": vals_right, "c": 1}) + result = left.merge(right, how="leftsemi", left_on="a", right_on="d") + tm.assert_frame_equal(result, expected) + + right = pd.DataFrame({"d": vals_right, "c": 1}) + result = left.merge(right, how="leftsemi", left_on=["a", "b"], right_on=["d", "c"]) + tm.assert_frame_equal(result, expected.head(1)) + + +def test_leftsemi_invalid(): + left = pd.DataFrame({"a": [1, 2, 3], "b": [1, 2, 3]}) + right = pd.DataFrame({"a": [1, 2], "c": 1}) + + msg = "left_index or right_index are not supported for semi-join." + with pytest.raises(NotImplementedError, match=msg): + left.merge(right, how="leftsemi", left_index=True, right_on="a") + with pytest.raises(NotImplementedError, match=msg): + left.merge(right, how="leftsemi", right_index=True, left_on="a") + + msg = "validate is not supported for semi-join." + with pytest.raises(NotImplementedError, match=msg): + left.merge(right, how="leftsemi", validate="one_to_one") + + msg = "indicator is not supported for semi-join." + with pytest.raises(NotImplementedError, match=msg): + left.merge(right, how="leftsemi", indicator=True) + + msg = "sort is not supported for semi-join. Sort your DataFrame afterwards." + with pytest.raises(NotImplementedError, match=msg): + left.merge(right, how="leftsemi", sort=True) From 267e29debe84505d588402f6f480672b7629dd4a Mon Sep 17 00:00:00 2001 From: Patrick Hoefler <61934744+phofl@users.noreply.github.com> Date: Sat, 23 Mar 2024 21:13:23 -0500 Subject: [PATCH 2/4] Docs --- doc/source/user_guide/merging.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/user_guide/merging.rst b/doc/source/user_guide/merging.rst index f7e7d1dd24317..3578dba6fa591 100644 --- a/doc/source/user_guide/merging.rst +++ b/doc/source/user_guide/merging.rst @@ -407,7 +407,7 @@ either the left or right tables, the values in the joined table will be ``right``, ``RIGHT OUTER JOIN``, Use keys from right frame only ``outer``, ``FULL OUTER JOIN``, Use union of keys from both frames ``inner``, ``INNER JOIN``, Use intersection of keys from both frames - ``leftsemi``, ``SEMIJOIN``, Filter rows on left based on occurrences in right. + ``leftsemi``, ``LEFT SEMI JOIN``, Filter rows on left based on occurrences in right. ``cross``, ``CROSS JOIN``, Create the cartesian product of rows of both frames .. ipython:: python From 58ea8457f872080a5f5963d0fa50c66b2defe345 Mon Sep 17 00:00:00 2001 From: Patrick Hoefler <61934744+phofl@users.noreply.github.com> Date: Sat, 23 Mar 2024 21:40:35 -0500 Subject: [PATCH 3/4] Fixup --- pandas/tests/reshape/merge/test_semi.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pandas/tests/reshape/merge/test_semi.py b/pandas/tests/reshape/merge/test_semi.py index 92b265fd75d13..6f54f32d62121 100644 --- a/pandas/tests/reshape/merge/test_semi.py +++ b/pandas/tests/reshape/merge/test_semi.py @@ -7,18 +7,21 @@ @pytest.mark.parametrize( - "vals_left, vals_right", + "vals_left, vals_right, dtype", [ - ([1, 2, 3], [1, 2]), - (["a", "b", "c"], ["a", "b"]), + ([1, 2, 3], [1, 2], "int64"), + (["a", "b", "c"], ["a", "b"], "object"), pytest.param( - pd.Series(["a", "b", "c"], dtype="string[pyarrow]"), - pd.Series(["a", "b"], dtype="string[pyarrow]"), + ["a", "b", "c"], + ["a", "b"], + "string[pyarrow]", marks=td.skip_if_no("pyarrow"), ), ], ) -def test_leftsemi(vals_left, vals_right): +def test_leftsemi(vals_left, vals_right, dtype): + vals_left = pd.Series(vals_left, dtype=dtype) + vals_right = pd.Series(vals_right, dtype=dtype) left = pd.DataFrame({"a": vals_left, "b": [1, 2, 3]}) right = pd.DataFrame({"a": vals_right, "c": 1}) expected = pd.DataFrame({"a": vals_right, "b": [1, 2]}) From e39bc4c6404d0b01d0f8fc377103a0defb4f9ecf Mon Sep 17 00:00:00 2001 From: Patrick Hoefler <61934744+phofl@users.noreply.github.com> Date: Sun, 24 Mar 2024 12:05:23 -0500 Subject: [PATCH 4/4] Fixup --- pandas/core/reshape/merge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/core/reshape/merge.py b/pandas/core/reshape/merge.py index 54f5b6d90c398..f7c515c135863 100644 --- a/pandas/core/reshape/merge.py +++ b/pandas/core/reshape/merge.py @@ -1681,7 +1681,7 @@ def get_join_indexers( left_keys: list[ArrayLike], right_keys: list[ArrayLike], sort: bool = False, - how: JoinHow = "inner", + how: JoinHow + Literal["leftsemi"] = "inner", ) -> tuple[npt.NDArray[np.intp] | None, npt.NDArray[np.intp] | None]: """ @@ -2612,7 +2612,7 @@ def _factorize_keys( elif how == "leftsemi": # populate hashtable for right and then do a hash join rizer.factorize(rk_data, mask=rk_mask) - return rizer.hash_inner_join(lk_data, lk_mask)[1], None, -1 + return rizer.hash_inner_join(lk_data, lk_mask)[1], None, -1 # type: ignore[return-value] else: llab = rizer.factorize(lk_data, mask=lk_mask) rlab = rizer.factorize(rk_data, mask=rk_mask)