Skip to content

Commit a629b82

Browse files
committed
BUG: fix unstacking with unused levels in columns/unstacked index level
closes pandas-dev#17845 closes pandas-dev#18562
1 parent bcce140 commit a629b82

File tree

3 files changed

+89
-18
lines changed

3 files changed

+89
-18
lines changed

doc/source/whatsnew/v0.23.0.txt

+2
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,8 @@ Reshaping
444444
^^^^^^^^^
445445

446446
- Bug in :func:`DataFrame.stack` which fails trying to sort mixed type levels under Python 3 (:issue:`18310`)
447+
- Bug in :func:`DataFrame.unstack` which casts int to float if ``columns`` is a ``MultiIndex`` with unused levels (:issue:`17845`)
448+
- Bug in :func:`DataFrame.unstack` which raises an error if ``index`` is a ``MultiIndex`` with unused labels on the unstacked level (:issue:`18562`)
447449
- Fixed construction of a :class:`Series` from a ``dict`` containing ``NaN`` as key (:issue:`18480`)
448450
- Bug in :func:`Series.rank` where ``Series`` containing ``NaT`` modifies the ``Series`` inplace (:issue:`18521`)
449451
- Bug in :func:`cut` which fails when using readonly arrays (:issue:`18773`)

pandas/core/reshape/reshape.py

+19-18
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,19 @@ def __init__(self, values, index, level=-1, value_columns=None,
8989
if value_columns is None and values.shape[1] != 1: # pragma: no cover
9090
raise ValueError('must pass column labels for multi-column data')
9191

92-
self.index = index
92+
self.index = index.remove_unused_levels()
9393

9494
self.level = self.index._get_level_number(level)
9595

9696
# when index includes `nan`, need to lift levels/strides by 1
9797
self.lift = 1 if -1 in self.index.labels[self.level] else 0
9898

99-
self.new_index_levels = list(index.levels)
100-
self.new_index_names = list(index.names)
99+
self.new_index_levels = list(self.index.levels)
100+
self.new_index_names = list(self.index.names)
101101

102102
self.removed_name = self.new_index_names.pop(self.level)
103103
self.removed_level = self.new_index_levels.pop(self.level)
104+
self.removed_level_full = index.levels[self.level]
104105

105106
self._make_sorted_values_labels()
106107
self._make_selectors()
@@ -150,21 +151,10 @@ def _make_selectors(self):
150151
self.compressor = comp_index.searchsorted(np.arange(ngroups))
151152

152153
def get_result(self):
153-
# TODO: find a better way than this masking business
154-
155-
values, value_mask = self.get_new_values()
154+
values, _ = self.get_new_values()
156155
columns = self.get_new_columns()
157156
index = self.get_new_index()
158157

159-
# filter out missing levels
160-
if values.shape[1] > 0:
161-
col_inds, obs_ids = compress_group_index(self.sorted_labels[-1])
162-
# rare case, level values not observed
163-
if len(obs_ids) < self.full_shape[1]:
164-
inds = (value_mask.sum(0) > 0).nonzero()[0]
165-
values = algos.take_nd(values, inds, axis=1)
166-
columns = columns[inds]
167-
168158
# may need to coerce categoricals here
169159
if self.is_categorical is not None:
170160
categories = self.is_categorical.categories
@@ -253,17 +243,28 @@ def get_new_columns(self):
253243
width = len(self.value_columns)
254244
propagator = np.repeat(np.arange(width), stride)
255245
if isinstance(self.value_columns, MultiIndex):
256-
new_levels = self.value_columns.levels + (self.removed_level,)
246+
new_levels = self.value_columns.levels + (self.removed_level_full,)
257247
new_names = self.value_columns.names + (self.removed_name,)
258248

259249
new_labels = [lab.take(propagator)
260250
for lab in self.value_columns.labels]
261251
else:
262-
new_levels = [self.value_columns, self.removed_level]
252+
new_levels = [self.value_columns, self.removed_level_full]
263253
new_names = [self.value_columns.name, self.removed_name]
264254
new_labels = [propagator]
265255

266-
new_labels.append(np.tile(np.arange(stride) - self.lift, width))
256+
# The two indices differ only if the unstacked level had unused items:
257+
if len(self.removed_level_full) != len(self.removed_level):
258+
# In this case, we remap the new labels to the original level:
259+
repeater = self.removed_level_full.get_indexer(self.removed_level)
260+
if self.lift:
261+
repeater = np.insert(repeater, 0, -1)
262+
else:
263+
# Otherwise, we just use each level item exactly once:
264+
repeater = np.arange(stride) - self.lift
265+
266+
# The entire level is then just a repetition of the single chunk:
267+
new_labels.append(np.tile(repeater, width))
267268
return MultiIndex(levels=new_levels, labels=new_labels,
268269
names=new_names, verify_integrity=False)
269270

pandas/tests/frame/test_reshape.py

+68
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,74 @@ def test_unstack_dtypes(self):
560560
assert left.shape == (3, 2)
561561
tm.assert_frame_equal(left, right)
562562

563+
def test_unstack_unused_levels(self):
564+
# GH 17845: unused labels in index make unstack() cast int to float
565+
idx = pd.MultiIndex.from_product([['a'], ['A', 'B', 'C', 'D']])[:-1]
566+
df = pd.DataFrame([[1, 0]] * 3, index=idx)
567+
568+
result = df.unstack()
569+
exp_col = pd.MultiIndex.from_product([[0, 1], ['A', 'B', 'C']])
570+
expected = pd.DataFrame([[1, 1, 1, 0, 0, 0]], index=['a'],
571+
columns=exp_col)
572+
tm.assert_frame_equal(result, expected)
573+
assert((result.columns.levels[1] == idx.levels[1]).all())
574+
575+
# Unused items on both levels
576+
levels = [[0, 1, 7], [0, 1, 2, 3]]
577+
labels = [[0, 0, 1, 1], [0, 2, 0, 2]]
578+
idx = pd.MultiIndex(levels, labels)
579+
block = np.arange(4).reshape(2, 2)
580+
df = pd.DataFrame(np.concatenate([block, block + 4]), index=idx)
581+
result = df.unstack()
582+
expected = pd.DataFrame(np.concatenate([block * 2, block * 2 + 1],
583+
axis=1),
584+
columns=idx)
585+
tm.assert_frame_equal(result, expected)
586+
assert((result.columns.levels[1] == idx.levels[1]).all())
587+
588+
# With mixed dtype and NaN
589+
levels = [['a', 2, 'c'], [1, 3, 5, 7]]
590+
labels = [[0, -1, 1, 1], [0, 2, -1, 2]]
591+
idx = pd.MultiIndex(levels, labels)
592+
data = np.arange(8)
593+
df = pd.DataFrame(data.reshape(4, 2), index=idx)
594+
595+
cases = ((0, [13, 16, 6, 9, 2, 5, 8, 11],
596+
[np.nan, 'a', 2], [np.nan, 5, 1]),
597+
(1, [8, 11, 1, 4, 12, 15, 13, 16],
598+
[np.nan, 5, 1], [np.nan, 'a', 2]))
599+
for level, idces, col_level, idx_level in cases:
600+
result = df.unstack(level=level)
601+
exp_data = np.zeros(18) * np.nan
602+
exp_data[idces] = data
603+
cols = pd.MultiIndex.from_product([[0, 1], col_level])
604+
expected = pd.DataFrame(exp_data.reshape(3, 6),
605+
index=idx_level, columns=cols)
606+
# Broken (GH 18455):
607+
# tm.assert_frame_equal(result, expected)
608+
diff = result - expected
609+
assert(diff.sum().sum() == 0)
610+
assert((diff + 1).sum().sum() == 8)
611+
612+
assert((result.columns.levels[1] == idx.levels[level]).all())
613+
614+
@pytest.mark.parametrize("cols", [['A', 'C'], slice(None)])
615+
def test_unstack_unused_level(self, cols):
616+
# GH 18562 : unused labels on the unstacked level
617+
df = pd.DataFrame([[2010, 'a', 'I'],
618+
[2011, 'b', 'II']],
619+
columns=['A', 'B', 'C'])
620+
621+
ind = df.set_index(['A', 'B', 'C'], drop=False)
622+
selection = ind.loc[(slice(None), slice(None), 'I'), cols]
623+
result = selection.unstack()
624+
625+
expected = ind.iloc[[0]][cols]
626+
expected.columns = MultiIndex.from_product([expected.columns, ['I']],
627+
names=[None, 'C'])
628+
expected.index = expected.index.droplevel('C')
629+
tm.assert_frame_equal(result, expected)
630+
563631
def test_unstack_nan_index(self): # GH7466
564632
cast = lambda val: '{0:1}'.format('' if val != val else val)
565633
nan = np.nan

0 commit comments

Comments
 (0)