Skip to content

Commit df039bf

Browse files
topper-123jreback
authored andcommitted
PERF/REF: improve performance of Series.searchsorted, PandasArray.searchsorted, collect functionality (#22034)
1 parent fc1fe83 commit df039bf

File tree

8 files changed

+175
-20
lines changed

8 files changed

+175
-20
lines changed

asv_bench/benchmarks/series_methods.py

+19
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,25 @@ def time_dropna(self, dtype):
124124
self.s.dropna()
125125

126126

127+
class SearchSorted(object):
128+
129+
goal_time = 0.2
130+
params = ['int8', 'int16', 'int32', 'int64',
131+
'uint8', 'uint16', 'uint32', 'uint64',
132+
'float16', 'float32', 'float64',
133+
'str']
134+
param_names = ['dtype']
135+
136+
def setup(self, dtype):
137+
N = 10**5
138+
data = np.array([1] * N + [2] * N + [3] * N).astype(dtype)
139+
self.s = Series(data)
140+
141+
def time_searchsorted(self, dtype):
142+
key = '2' if dtype == 'str' else 2
143+
self.s.searchsorted(key)
144+
145+
127146
class Map(object):
128147

129148
params = ['dict', 'Series']

doc/source/whatsnew/v0.25.0.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ Performance Improvements
9696

9797
- Significant speedup in `SparseArray` initialization that benefits most operations, fixing performance regression introduced in v0.20.0 (:issue:`24985`)
9898
- `DataFrame.to_stata()` is now faster when outputting data with any string or non-native endian columns (:issue:`25045`)
99-
-
99+
- Improved performance of :meth:`Series.searchsorted`. The speedup is especially large when the dtype is
100+
int8/int16/int32 and the searched key is within the integer bounds for the dtype (:issue:`22034`)
100101

101102

102103
.. _whatsnew_0250.bug_fixes:

pandas/core/algorithms.py

+84-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
ensure_float64, ensure_int64, ensure_object, ensure_platform_int,
2020
ensure_uint64, is_array_like, is_bool_dtype, is_categorical_dtype,
2121
is_complex_dtype, is_datetime64_any_dtype, is_datetime64tz_dtype,
22-
is_datetimelike, is_extension_array_dtype, is_float_dtype,
22+
is_datetimelike, is_extension_array_dtype, is_float_dtype, is_integer,
2323
is_integer_dtype, is_interval_dtype, is_list_like, is_numeric_dtype,
2424
is_object_dtype, is_period_dtype, is_scalar, is_signed_integer_dtype,
2525
is_sparse, is_timedelta64_dtype, is_unsigned_integer_dtype,
@@ -1724,6 +1724,89 @@ def func(arr, indexer, out, fill_value=np.nan):
17241724
return out
17251725

17261726

1727+
# ------------ #
1728+
# searchsorted #
1729+
# ------------ #
1730+
1731+
def searchsorted(arr, value, side="left", sorter=None):
1732+
"""
1733+
Find indices where elements should be inserted to maintain order.
1734+
1735+
.. versionadded:: 0.25.0
1736+
1737+
Find the indices into a sorted array `arr` (a) such that, if the
1738+
corresponding elements in `value` were inserted before the indices,
1739+
the order of `arr` would be preserved.
1740+
1741+
Assuming that `arr` is sorted:
1742+
1743+
====== ================================
1744+
`side` returned index `i` satisfies
1745+
====== ================================
1746+
left ``arr[i-1] < value <= self[i]``
1747+
right ``arr[i-1] <= value < self[i]``
1748+
====== ================================
1749+
1750+
Parameters
1751+
----------
1752+
arr: array-like
1753+
Input array. If `sorter` is None, then it must be sorted in
1754+
ascending order, otherwise `sorter` must be an array of indices
1755+
that sort it.
1756+
value : array_like
1757+
Values to insert into `arr`.
1758+
side : {'left', 'right'}, optional
1759+
If 'left', the index of the first suitable location found is given.
1760+
If 'right', return the last such index. If there is no suitable
1761+
index, return either 0 or N (where N is the length of `self`).
1762+
sorter : 1-D array_like, optional
1763+
Optional array of integer indices that sort array a into ascending
1764+
order. They are typically the result of argsort.
1765+
1766+
Returns
1767+
-------
1768+
array of ints
1769+
Array of insertion points with the same shape as `value`.
1770+
1771+
See Also
1772+
--------
1773+
numpy.searchsorted : Similar method from NumPy.
1774+
"""
1775+
if sorter is not None:
1776+
sorter = ensure_platform_int(sorter)
1777+
1778+
if isinstance(arr, np.ndarray) and is_integer_dtype(arr) and (
1779+
is_integer(value) or is_integer_dtype(value)):
1780+
from .arrays.array_ import array
1781+
# if `arr` and `value` have different dtypes, `arr` would be
1782+
# recast by numpy, causing a slow search.
1783+
# Before searching below, we therefore try to give `value` the
1784+
# same dtype as `arr`, while guarding against integer overflows.
1785+
iinfo = np.iinfo(arr.dtype.type)
1786+
value_arr = np.array([value]) if is_scalar(value) else np.array(value)
1787+
if (value_arr >= iinfo.min).all() and (value_arr <= iinfo.max).all():
1788+
# value within bounds, so no overflow, so can convert value dtype
1789+
# to dtype of arr
1790+
dtype = arr.dtype
1791+
else:
1792+
dtype = value_arr.dtype
1793+
1794+
if is_scalar(value):
1795+
value = dtype.type(value)
1796+
else:
1797+
value = array(value, dtype=dtype)
1798+
elif not (is_object_dtype(arr) or is_numeric_dtype(arr) or
1799+
is_categorical_dtype(arr)):
1800+
from pandas.core.series import Series
1801+
# E.g. if `arr` is an array with dtype='datetime64[ns]'
1802+
# and `value` is a pd.Timestamp, we may need to convert value
1803+
value_ser = Series(value)._values
1804+
value = value_ser[0] if is_scalar(value) else value_ser
1805+
1806+
result = arr.searchsorted(value, side=side, sorter=sorter)
1807+
return result
1808+
1809+
17271810
# ---- #
17281811
# diff #
17291812
# ---- #

pandas/core/arrays/base.py

+9-9
Original file line numberDiff line numberDiff line change
@@ -555,17 +555,17 @@ def searchsorted(self, value, side="left", sorter=None):
555555
.. versionadded:: 0.24.0
556556
557557
Find the indices into a sorted array `self` (a) such that, if the
558-
corresponding elements in `v` were inserted before the indices, the
559-
order of `self` would be preserved.
558+
corresponding elements in `value` were inserted before the indices,
559+
the order of `self` would be preserved.
560560
561-
Assuming that `a` is sorted:
561+
Assuming that `self` is sorted:
562562
563-
====== ============================
563+
====== ================================
564564
`side` returned index `i` satisfies
565-
====== ============================
566-
left ``self[i-1] < v <= self[i]``
567-
right ``self[i-1] <= v < self[i]``
568-
====== ============================
565+
====== ================================
566+
left ``self[i-1] < value <= self[i]``
567+
right ``self[i-1] <= value < self[i]``
568+
====== ================================
569569
570570
Parameters
571571
----------
@@ -581,7 +581,7 @@ def searchsorted(self, value, side="left", sorter=None):
581581
582582
Returns
583583
-------
584-
indices : array of ints
584+
array of ints
585585
Array of insertion points with the same shape as `value`.
586586
587587
See Also

pandas/core/arrays/numpy_.py

+7
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from pandas._libs import lib
66
from pandas.compat.numpy import function as nv
7+
from pandas.util._decorators import Appender
78
from pandas.util._validators import validate_fillna_kwargs
89

910
from pandas.core.dtypes.dtypes import ExtensionDtype
@@ -12,6 +13,7 @@
1213

1314
from pandas import compat
1415
from pandas.core import nanops
16+
from pandas.core.algorithms import searchsorted
1517
from pandas.core.missing import backfill_1d, pad_1d
1618

1719
from .base import ExtensionArray, ExtensionOpsMixin
@@ -423,6 +425,11 @@ def to_numpy(self, dtype=None, copy=False):
423425

424426
return result
425427

428+
@Appender(ExtensionArray.searchsorted.__doc__)
429+
def searchsorted(self, value, side='left', sorter=None):
430+
return searchsorted(self.to_numpy(), value,
431+
side=side, sorter=sorter)
432+
426433
# ------------------------------------------------------------------------
427434
# Ops
428435

pandas/core/base.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1522,11 +1522,11 @@ def factorize(self, sort=False, na_sentinel=-1):
15221522
array([3])
15231523
""")
15241524

1525-
@Substitution(klass='IndexOpsMixin')
1525+
@Substitution(klass='Index')
15261526
@Appender(_shared_docs['searchsorted'])
15271527
def searchsorted(self, value, side='left', sorter=None):
1528-
# needs coercion on the key (DatetimeIndex does already)
1529-
return self._values.searchsorted(value, side=side, sorter=sorter)
1528+
return algorithms.searchsorted(self._values, value,
1529+
side=side, sorter=sorter)
15301530

15311531
def drop_duplicates(self, keep='first', inplace=False):
15321532
inplace = validate_bool_kwarg(inplace, 'inplace')

pandas/core/series.py

+2-6
Original file line numberDiff line numberDiff line change
@@ -2392,12 +2392,8 @@ def __rmatmul__(self, other):
23922392
@Substitution(klass='Series')
23932393
@Appender(base._shared_docs['searchsorted'])
23942394
def searchsorted(self, value, side='left', sorter=None):
2395-
if sorter is not None:
2396-
sorter = ensure_platform_int(sorter)
2397-
result = self._values.searchsorted(Series(value)._values,
2398-
side=side, sorter=sorter)
2399-
2400-
return result[0] if is_scalar(value) else result
2395+
return algorithms.searchsorted(self._values, value,
2396+
side=side, sorter=sorter)
24012397

24022398
# -------------------------------------------------------------------
24032399
# Combination

pandas/tests/arrays/test_array.py

+49
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import pandas as pd
1111
from pandas.api.extensions import register_extension_dtype
12+
from pandas.api.types import is_scalar
1213
from pandas.core.arrays import PandasArray, integer_array, period_array
1314
from pandas.tests.extension.decimal import (
1415
DecimalArray, DecimalDtype, to_decimal)
@@ -254,3 +255,51 @@ def test_array_not_registered(registry_without_decimal):
254255
result = pd.array(data, dtype=DecimalDtype)
255256
expected = DecimalArray._from_sequence(data)
256257
tm.assert_equal(result, expected)
258+
259+
260+
class TestArrayAnalytics(object):
261+
def test_searchsorted(self, string_dtype):
262+
arr = pd.array(['a', 'b', 'c'], dtype=string_dtype)
263+
264+
result = arr.searchsorted('a', side='left')
265+
assert is_scalar(result)
266+
assert result == 0
267+
268+
result = arr.searchsorted('a', side='right')
269+
assert is_scalar(result)
270+
assert result == 1
271+
272+
def test_searchsorted_numeric_dtypes_scalar(self, any_real_dtype):
273+
arr = pd.array([1, 3, 90], dtype=any_real_dtype)
274+
result = arr.searchsorted(30)
275+
assert is_scalar(result)
276+
assert result == 2
277+
278+
result = arr.searchsorted([30])
279+
expected = np.array([2], dtype=np.intp)
280+
tm.assert_numpy_array_equal(result, expected)
281+
282+
def test_searchsorted_numeric_dtypes_vector(self, any_real_dtype):
283+
arr = pd.array([1, 3, 90], dtype=any_real_dtype)
284+
result = arr.searchsorted([2, 30])
285+
expected = np.array([1, 2], dtype=np.intp)
286+
tm.assert_numpy_array_equal(result, expected)
287+
288+
@pytest.mark.parametrize('arr, val', [
289+
[pd.date_range('20120101', periods=10, freq='2D'),
290+
pd.Timestamp('20120102')],
291+
[pd.date_range('20120101', periods=10, freq='2D', tz='Asia/Hong_Kong'),
292+
pd.Timestamp('20120102', tz='Asia/Hong_Kong')],
293+
[pd.timedelta_range(start='1 day', end='10 days', periods=10),
294+
pd.Timedelta('2 days')]])
295+
def test_search_sorted_datetime64_scalar(self, arr, val):
296+
arr = pd.array(arr)
297+
result = arr.searchsorted(val)
298+
assert is_scalar(result)
299+
assert result == 1
300+
301+
def test_searchsorted_sorter(self, any_real_dtype):
302+
arr = pd.array([3, 1, 2], dtype=any_real_dtype)
303+
result = arr.searchsorted([0, 3], sorter=np.argsort(arr))
304+
expected = np.array([0, 2], dtype=np.intp)
305+
tm.assert_numpy_array_equal(result, expected)

0 commit comments

Comments
 (0)