Repository: modin-project/modin

Issue:
searchsorted function implementation



Discussion:

Resolving patch:
diff --git a/modin/backends/base/query_compiler.py b/modin/backends/base/query_compiler.py
--- a/modin/backends/base/query_compiler.py
+++ b/modin/backends/base/query_compiler.py
@@ -537,6 +537,10 @@ def to_numeric(self, arg, **kwargs):
     def unique(self, **kwargs):
         pass
 
+    @abc.abstractmethod
+    def searchsorted(self, **kwargs):
+        pass
+
     # END Abstract map partitions operations
 
     def value_counts(self, **kwargs):
diff --git a/modin/backends/pandas/query_compiler.py b/modin/backends/pandas/query_compiler.py
--- a/modin/backends/pandas/query_compiler.py
+++ b/modin/backends/pandas/query_compiler.py
@@ -17,6 +17,7 @@
     is_list_like,
     is_numeric_dtype,
     is_datetime_or_timedelta_dtype,
+    is_scalar,
 )
 from pandas.core.base import DataError
 
@@ -1267,6 +1268,94 @@ def unique(self):
         )
         return self.__constructor__(new_modin_frame)
 
+    def searchsorted(self, **kwargs):
+        """
+        Return a QueryCompiler with value/values indicies, which they should be inserted
+        to maintain order of the passed Series.
+
+        Returns
+        -------
+        PandasQueryCompiler
+        """
+
+        def map_func(part, *args, **kwargs):
+
+            elements_number = len(part.index)
+            assert elements_number > 0, "Wrong mapping behaviour of MapReduce"
+
+            # unify value type
+            value = kwargs.pop("value")
+            value = np.array([value]) if is_scalar(value) else value
+
+            if elements_number == 1:
+                part = part[part.columns[0]]
+            else:
+                part = part.squeeze()
+
+            part_index_start = part.index.start
+            part_index_stop = part.index.stop
+
+            result = part.searchsorted(value=value, *args, **kwargs)
+
+            processed_results = {}
+            value_number = 0
+            for value_result in result:
+                value_result += part_index_start
+
+                if value_result > part_index_start and value_result < part_index_stop:
+                    processed_results[f"value{value_number}"] = {
+                        "relative_location": "current_partition",
+                        "index": value_result,
+                    }
+                elif value_result <= part_index_start:
+                    processed_results[f"value{value_number}"] = {
+                        "relative_location": "previoius_partitions",
+                        "index": part_index_start,
+                    }
+                else:
+                    processed_results[f"value{value_number}"] = {
+                        "relative_location": "next_partitions",
+                        "index": part_index_stop,
+                    }
+
+                value_number += 1
+
+            return pandas.DataFrame(processed_results)
+
+        def reduce_func(map_results, *args, **kwargs):
+            def get_value_index(value_result):
+                value_result_grouped = value_result.groupby(level=0)
+                rel_location = value_result_grouped.get_group("relative_location")
+                ind = value_result_grouped.get_group("index")
+                # executes if result is inside of the mapped part
+                if "current_partition" in rel_location.values:
+                    assert (
+                        rel_location[rel_location == "current_partition"].count() == 1
+                    ), "Each value should have single result"
+                    return ind[rel_location.values == "current_partition"]
+                # executes if result is between mapped parts
+                elif rel_location.nunique(dropna=False) > 1:
+                    return ind[rel_location.values == "previoius_partitions"][0]
+                # executes if result is outside of the mapped part
+                else:
+                    if "next_partitions" in rel_location.values:
+                        return ind[-1]
+                    else:
+                        return ind[0]
+
+            map_results_parsed = map_results.apply(
+                lambda ser: get_value_index(ser)
+            ).squeeze()
+
+            if isinstance(map_results_parsed, pandas.Series):
+                map_results_parsed = map_results_parsed.to_list()
+
+            return pandas.Series(map_results_parsed)
+
+        return MapReduceFunction.register(map_func, reduce_func, preserve_index=False)(
+            self, **kwargs
+        )
+
     # Dt map partitions operations
 
     dt_date = MapFunction.register(_dt_prop_map("date"))
diff --git a/modin/pandas/series.py b/modin/pandas/series.py
--- a/modin/pandas/series.py
+++ b/modin/pandas/series.py
@@ -1394,9 +1394,58 @@ def replace(
         return self._create_or_update_from_compiler(new_query_compiler, inplace)
 
     def searchsorted(self, value, side="left", sorter=None):
-        return self._default_to_pandas(
-            pandas.Series.searchsorted, value, side=side, sorter=sorter
-        )
+        """
+        Find indices where elements should be inserted to maintain order.
+
+        Find the indices into a sorted Series self such that, if the
+        corresponding elements in value were inserted before the indices,
+        the order of self would be preserved.
+
+        Parameters
+        ----------
+        value: array_like
+            Values to insert into self.
+        side: {"left", "right"}, optional
+            If "left", the index of the first suitable location found is
+            given. If "right", return the last such index. If there is no
+            suitable index, return either 0 or N (where N is the length of self).
+        sorter: 1-D array_like, optional
+            Optional array of integer indices that sort self into ascending order.
+            They are typically the result of np.argsort.
+
+        Returns
+        -------
+        int or array of int
+            A scalar or array of insertion points with the same shape as value.
+        """
+        searchsorted_qc = self._query_compiler
+        if sorter is not None:
+            # `iloc` method works slowly (https://github.com/modin-project/modin/issues/1903),
+            # so _default_to_pandas is used for now
+            # searchsorted_qc = self.iloc[sorter].reset_index(drop=True)._query_compiler
+            # sorter = None
+            return self._default_to_pandas(
+                pandas.Series.searchsorted, value, side=side, sorter=sorter
+            )
+        # searchsorted should return item number irrespective of Series index, so
+        # Series.index is always set to pandas.RangeIndex, which can be easily processed
+        # on the query_compiler level
+        if not isinstance(searchsorted_qc.index, pandas.RangeIndex):
+            searchsorted_qc = searchsorted_qc.reset_index(drop=True)
+
+        result = self.__constructor__(
+            query_compiler=searchsorted_qc.searchsorted(
+                value=value, side=side, sorter=sorter
+            )
+        ).squeeze()
+
+        # matching Pandas output
+        if not is_scalar(value) and not is_list_like(result):
+            result = np.array([result])
+        elif isinstance(result, type(self)):
+            result = result.to_numpy()
+
+        return result
 
     def sort_values(
         self,


Test patch:
diff --git a/modin/pandas/test/test_series.py b/modin/pandas/test/test_series.py
--- a/modin/pandas/test/test_series.py
+++ b/modin/pandas/test/test_series.py
@@ -172,13 +172,16 @@ def inter_df_math_helper_one_side(modin_series, pandas_series, op):
         pass
 
 
-def create_test_series(vals):
+def create_test_series(vals, sort=False):
     if isinstance(vals, dict):
         modin_series = pd.Series(vals[next(iter(vals.keys()))])
         pandas_series = pandas.Series(vals[next(iter(vals.keys()))])
     else:
         modin_series = pd.Series(vals)
         pandas_series = pandas.Series(vals)
+    if sort:
+        modin_series = modin_series.sort_values().reset_index(drop=True)
+        pandas_series = pandas_series.sort_values().reset_index(drop=True)
     return modin_series, pandas_series
 
 
@@ -2611,11 +2614,69 @@ def test_sample(data):
         modin_series.sample(n=-3)
 
 
+@pytest.mark.parametrize("single_value_data", [True, False])
+@pytest.mark.parametrize("use_multiindex", [True, False])
+@pytest.mark.parametrize("sorter", [True, None])
+@pytest.mark.parametrize("values_number", [1, 2, 5])
+@pytest.mark.parametrize("side", ["left", "right"])
 @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys)
-def test_searchsorted(data):
-    modin_series, pandas_series = create_test_series(data)
-    with pytest.warns(UserWarning):
-        modin_series.searchsorted(3)
+def test_searchsorted(
+    data, side, values_number, sorter, use_multiindex, single_value_data
+):
+    data = data if not single_value_data else data[next(iter(data.keys()))][0]
+    if not sorter:
+        modin_series, pandas_series = create_test_series(vals=data, sort=True)
+    else:
+        modin_series, pandas_series = create_test_series(vals=data)
+        sorter = np.argsort(list(modin_series))
+
+    if use_multiindex:
+        rows_number = len(modin_series.index)
+        level_0_series = random_state.choice([0, 1], rows_number)
+        level_1_series = random_state.choice([2, 3], rows_number)
+        index_series = pd.MultiIndex.from_arrays(
+            [level_0_series, level_1_series], names=["first", "second"]
+        )
+        modin_series.index = index_series
+        pandas_series.index = index_series
+
+    min_sample = modin_series.min(skipna=True)
+    max_sample = modin_series.max(skipna=True)
+
+    if single_value_data:
+        values = [data]
+    else:
+        values = []
+        values.append(pandas_series.sample(n=values_number, random_state=random_state))
+        values.append(
+            random_state.uniform(low=min_sample, high=max_sample, size=values_number)
+        )
+        values.append(
+            random_state.uniform(
+                low=max_sample, high=2 * max_sample, size=values_number
+            )
+        )
+        values.append(
+            random_state.uniform(
+                low=min_sample - max_sample, high=min_sample, size=values_number
+            )
+        )
+        pure_float = random_state.uniform(float(min_sample), float(max_sample))
+        pure_int = int(pure_float)
+        values.append(pure_float)
+        values.append(pure_int)
+
+    test_cases = [
+        modin_series.searchsorted(value=value, side=side, sorter=sorter)
+        == pandas_series.searchsorted(value=value, side=side, sorter=sorter)
+        for value in values
+    ]
+    test_cases = [
+        case.all() if not isinstance(case, bool) else case for case in test_cases
+    ]
+
+    for case in test_cases:
+        assert case
 
 
 @pytest.mark.parametrize("data", test_data_values, ids=test_data_keys)