Skip to content

BUG: Index._searchsorted_monotonic(..., side='right') returns the left side position for monotonic decreasing indexes #17272

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Sep 7, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/source/whatsnew/v0.21.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ Indexing
- Bug in ``.isin()`` in which checking membership in empty ``Series`` objects raised an error (:issue:`16991`)
- Bug in ``CategoricalIndex`` reindexing in which specified indices containing duplicates were not being respected (:issue:`17323`)
- Bug in intersection of ``RangeIndex`` with negative step (:issue:`17296`)
- Bug in ``IntervalIndex`` where performing a scalar lookup fails for included right endpoints of non-overlapping monotonic decreasing indexes (:issue:`16417`, :issue:`17271`)

I/O
^^^
Expand Down
2 changes: 1 addition & 1 deletion pandas/core/indexes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3465,7 +3465,7 @@ def _searchsorted_monotonic(self, label, side='left'):
# everything for it to work (element ordering, search side and
# resulting value).
pos = self[::-1].searchsorted(label, side='right' if side == 'left'
else 'right')
else 'left')
return len(self) - pos

raise ValueError('index must be monotonic increasing or decreasing')
Expand Down
59 changes: 55 additions & 4 deletions pandas/tests/indexes/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
RangeIndex, MultiIndex, CategoricalIndex, DatetimeIndex,
TimedeltaIndex, PeriodIndex, IntervalIndex,
notna, isna)
from pandas.core.indexes.base import InvalidIndexError
from pandas.core.indexes.datetimelike import DatetimeIndexOpsMixin
from pandas.core.dtypes.common import needs_i8_conversion
from pandas._libs.tslib import iNaT
Expand Down Expand Up @@ -138,9 +139,14 @@ def test_get_indexer_consistency(self):
if isinstance(index, IntervalIndex):
continue

indexer = index.get_indexer(index[0:2])
assert isinstance(indexer, np.ndarray)
assert indexer.dtype == np.intp
if index.is_unique or isinstance(index, CategoricalIndex):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jschendel why is CategoricalIndex excluded here?

indexer = index.get_indexer(index[0:2])
assert isinstance(indexer, np.ndarray)
assert indexer.dtype == np.intp
else:
e = "Reindexing only valid with uniquely valued Index objects"
with tm.assert_raises_regex(InvalidIndexError, e):
indexer = index.get_indexer(index[0:2])

indexer, _ = index.get_indexer_non_unique(index[0:2])
assert isinstance(indexer, np.ndarray)
Expand Down Expand Up @@ -632,7 +638,8 @@ def test_difference_base(self):
pass
elif isinstance(idx, (DatetimeIndex, TimedeltaIndex)):
assert result.__class__ == answer.__class__
tm.assert_numpy_array_equal(result.asi8, answer.asi8)
tm.assert_numpy_array_equal(result.sort_values().asi8,
answer.sort_values().asi8)
else:
result = first.difference(case)
assert tm.equalContents(result, answer)
Expand Down Expand Up @@ -954,3 +961,47 @@ def test_join_self_unique(self, how):
if index.is_unique:
joined = index.join(index, how=how)
assert (index == joined).all()

def test_searchsorted_monotonic(self):
# GH17271
for index in self.indices.values():
# not implemented for tuple searches in MultiIndex
# or Intervals searches in IntervalIndex
if isinstance(index, (MultiIndex, IntervalIndex)):
continue

# nothing to test if the index is empty
if index.empty:
continue
value = index[0]

# determine the expected results (handle dupes for 'right')
expected_left, expected_right = 0, (index == value).argmin()
if expected_right == 0:
# all values are the same, expected_right should be length
expected_right = len(index)

# test _searchsorted_monotonic in all cases
# test searchsorted only for increasing
if index.is_monotonic_increasing:
ssm_left = index._searchsorted_monotonic(value, side='left')
assert expected_left == ssm_left

ssm_right = index._searchsorted_monotonic(value, side='right')
assert expected_right == ssm_right

ss_left = index.searchsorted(value, side='left')
assert expected_left == ss_left

ss_right = index.searchsorted(value, side='right')
assert expected_right == ss_right
elif index.is_monotonic_decreasing:
ssm_left = index._searchsorted_monotonic(value, side='left')
assert expected_left == ssm_left

ssm_right = index._searchsorted_monotonic(value, side='right')
assert expected_right == ssm_right
else:
# non-monotonic should raise.
with pytest.raises(ValueError):
index._searchsorted_monotonic(value, side='left')
4 changes: 3 additions & 1 deletion pandas/tests/indexes/datetimes/test_datetimelike.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ class TestDatetimeIndex(DatetimeLike):
_holder = DatetimeIndex

def setup_method(self, method):
self.indices = dict(index=tm.makeDateIndex(10))
self.indices = dict(index=tm.makeDateIndex(10),
index_dec=date_range('20130110', periods=10,
freq='-1D'))
self.setup_indices()

def create_index(self):
Expand Down
4 changes: 3 additions & 1 deletion pandas/tests/indexes/period/test_period.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ class TestPeriodIndex(DatetimeLike):
_multiprocess_can_split_ = True

def setup_method(self, method):
self.indices = dict(index=tm.makePeriodIndex(10))
self.indices = dict(index=tm.makePeriodIndex(10),
index_dec=period_range('20130101', periods=10,
freq='D')[::-1])
self.setup_indices()

def create_index(self):
Expand Down
3 changes: 2 additions & 1 deletion pandas/tests/indexes/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ def setup_method(self, method):
catIndex=tm.makeCategoricalIndex(100),
empty=Index([]),
tuples=MultiIndex.from_tuples(lzip(
['foo', 'bar', 'baz'], [1, 2, 3])))
['foo', 'bar', 'baz'], [1, 2, 3])),
repeats=Index([0, 0, 1, 1, 2, 2]))
self.setup_indices()

def create_index(self):
Expand Down
12 changes: 8 additions & 4 deletions pandas/tests/indexes/test_numeric.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,9 @@ class TestFloat64Index(Numeric):

def setup_method(self, method):
self.indices = dict(mixed=Float64Index([1.5, 2, 3, 4, 5]),
float=Float64Index(np.arange(5) * 2.5))
float=Float64Index(np.arange(5) * 2.5),
mixed_dec=Float64Index([5, 4, 3, 2, 1.5]),
float_dec=Float64Index(np.arange(4, -1, -1) * 2.5))
self.setup_indices()

def create_index(self):
Expand Down Expand Up @@ -654,7 +656,8 @@ class TestInt64Index(NumericInt):
_holder = Int64Index

def setup_method(self, method):
self.indices = dict(index=Int64Index(np.arange(0, 20, 2)))
self.indices = dict(index=Int64Index(np.arange(0, 20, 2)),
index_dec=Int64Index(np.arange(19, -1, -1)))
self.setup_indices()

def create_index(self):
Expand Down Expand Up @@ -949,8 +952,9 @@ class TestUInt64Index(NumericInt):
_holder = UInt64Index

def setup_method(self, method):
self.indices = dict(index=UInt64Index([2**63, 2**63 + 10, 2**63 + 15,
2**63 + 20, 2**63 + 25]))
vals = [2**63, 2**63 + 10, 2**63 + 15, 2**63 + 20, 2**63 + 25]
self.indices = dict(index=UInt64Index(vals),
index_dec=UInt64Index(reversed(vals)))
self.setup_indices()

def create_index(self):
Expand Down
3 changes: 2 additions & 1 deletion pandas/tests/indexes/test_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ class TestRangeIndex(Numeric):
_compat_props = ['shape', 'ndim', 'size', 'itemsize']

def setup_method(self, method):
self.indices = dict(index=RangeIndex(0, 20, 2, name='foo'))
self.indices = dict(index=RangeIndex(0, 20, 2, name='foo'),
index_dec=RangeIndex(18, -1, -2, name='bar'))
self.setup_indices()

def create_index(self):
Expand Down
56 changes: 36 additions & 20 deletions pandas/tests/indexing/test_interval.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pandas as pd

from pandas import Series, DataFrame, IntervalIndex, Interval
from pandas.compat import product
import pandas.util.testing as tm


Expand All @@ -14,16 +15,6 @@ def setup_method(self, method):
def test_loc_with_scalar(self):

s = self.s
expected = 0

result = s.loc[0.5]
assert result == expected

result = s.loc[1]
assert result == expected

with pytest.raises(KeyError):
s.loc[0]

expected = s.iloc[:3]
tm.assert_series_equal(expected, s.loc[:3])
Expand All @@ -42,16 +33,6 @@ def test_loc_with_scalar(self):
def test_getitem_with_scalar(self):

s = self.s
expected = 0

result = s[0.5]
assert result == expected

result = s[1]
assert result == expected

with pytest.raises(KeyError):
s[0]

expected = s.iloc[:3]
tm.assert_series_equal(expected, s[:3])
Expand All @@ -67,6 +48,41 @@ def test_getitem_with_scalar(self):
expected = s.iloc[2:5]
tm.assert_series_equal(expected, s[s >= 2])

@pytest.mark.parametrize('direction, closed',
product(('increasing', 'decreasing'),
('left', 'right', 'neither', 'both')))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zfrenchee if you'd have a look here

def test_nonoverlapping_monotonic(self, direction, closed):
tpls = [(0, 1), (2, 3), (4, 5)]
if direction == 'decreasing':
tpls = reversed(tpls)

idx = IntervalIndex.from_tuples(tpls, closed=closed)
s = Series(list('abc'), idx)

for key, expected in zip(idx.left, s):
if idx.closed_left:
assert s[key] == expected
assert s.loc[key] == expected
else:
with pytest.raises(KeyError):
s[key]
with pytest.raises(KeyError):
s.loc[key]

for key, expected in zip(idx.right, s):
if idx.closed_right:
assert s[key] == expected
assert s.loc[key] == expected
else:
with pytest.raises(KeyError):
s[key]
with pytest.raises(KeyError):
s.loc[key]

for key, expected in zip(idx.mid, s):
assert s[key] == expected
assert s.loc[key] == expected

def test_with_interval(self):

s = self.s
Expand Down