pandas-dev/pandas · #71235

BUG: loc setitem with a partial MultiIndex key sets NaN (GH#43719)

jbrockmendel · merged Nov 10, 20263 files · 245 + / 4 −
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -219,6 +219,7 @@ MultiIndex - Bug in :meth:`MultiIndex.append`, :func:`concat`, :meth:`DataFrame.join` and :func:`merge` emitting a spurious ``RuntimeWarning`` when :class:`MultiIndex` level values could not be sorted (:issue:`61477`) - Bug in :meth:`MultiIndex.from_tuples` with an object ``ndarray``, :meth:`MultiIndex.append` and :meth:`Index.map` truncating or raising ``IndexError`` on tuples of different lengths; :meth:`MultiIndex.from_tuples` now raises a ``ValueError`` for an ``ndarray`` of tuples of different lengths (:issue:`50293`) - Bug in :meth:`Series.map`, :meth:`Index.get_indexer`, :meth:`DataFrame.reindex` and :attr:`DataFrame.loc` with a :class:`MultiIndex` raising ``IndexError`` or matching on a prefix when looking up tuples whose length differs from the number of levels (:issue:`50293`)+- Bug in :meth:`DataFrame.loc` and :meth:`Series.loc` setitem with a partial :class:`MultiIndex` key (e.g. ``df.loc[:, "a"] = df.loc[:, "a"] * 2``) setting ``NaN`` (or raising) instead of the value (:issue:`10440`, :issue:`15310`, :issue:`40186`, :issue:`43719`, :issue:`45751`, :issue:`46837`) -  I/O
pandas/core/indexing.py81 + / 0 −
@@ -932,6 +932,71 @@ def _maybe_mask_setitem_value(self, indexer, value):          return indexer, value +    @final+    def _maybe_restore_dropped_levels(self, key, indexer, value):+        """+        Relabel a Series/DataFrame value whose axis lacks the MultiIndex levels+        matched by the leading scalars of that axis's key.++        ``obj.loc[partial_key]`` drops those levels, so without this+        ``obj.loc[partial_key] = obj.loc[partial_key] * 2`` aligns to all-NaN.+        """+        if not isinstance(value, (ABCSeries, ABCDataFrame)):+            return value++        if self.axis is not None:+            key = _tupleize_axis_indexer(self.ndim, self.axis, key)+        # mirror how _get_setitem_indexer split the key between the axes+        if isinstance(indexer, tuple):+            keys = key+        else:+            keys, indexer = (key,), (indexer,)++        if value.ndim == 2:+            axes = list(range(len(indexer)))+        elif self.ndim == 1 or not (+            # a dict is a missing scalar key, see convert_missing_indexer+            is_scalar(indexer[0]) or isinstance(indexer[0], dict)+        ):+            # a Series value aligns on the rows unless they are selected by a scalar+            axes = [0]+        elif len(indexer) > 1:+            axes = [1]+        else:+            return value++        for axis in axes:+            ax = self.obj._get_axis(axis)+            idx = indexer[axis]+            value_axis = axis if value.ndim == 2 else 0+            value_ax = value._get_axis(value_axis)+            ndropped = ax.nlevels - value_ax.nlevels+            if (+                not isinstance(ax, MultiIndex)+                or not 0 < ndropped <= _count_leading_scalars(keys[axis])+                or not (+                    isinstance(idx, slice)+                    or (is_list_like_indexer(idx) and getattr(idx, "ndim", 1) == 1)+                )+                or isinstance(idx, dict)+            ):+                continue+            target = ax[idx]+            if not len(target) or not target.is_unique or not value_ax.is_unique:+                continue+            dropped = target._drop_level_numbers(list(range(ndropped))[::-1])+            # dropped repeats labels if the key matched several, see+            #  test_loc_setitem_partial_key_matching_several_labels+            positions = value_ax.get_indexer(dropped)+            found = positions != -1+            if not found.any():+                # e.g. the value is already labeled like the target+                continue+            # labels unmatched on either side are left to the usual alignment+            value = value.take(positions[found], axis=value_axis)+            value = value.set_axis(target[found], axis=value_axis)+        return value+     @final     def _ensure_listlike_indexer(self, key, axis=None) -> None:         """@@ -1004,6 +1069,8 @@ def __setitem__(self, key, value) -> None:         orig_nrows = self.obj.shape[0]         indexer = self._get_setitem_indexer(key)  # may alter self.obj         self._has_valid_setitem_indexer(key)+        if self.name == "loc":+            value = self._maybe_restore_dropped_levels(key, indexer, value)          # Capture dtype info cheaply between potential column expansion         # (above) and potential row expansion (below).@@ -3662,6 +3729,20 @@ def _tuplify(ndim: int, loc: Hashable) -> tuple[Hashable | slice, ...]:     return tuple(_tup)  +def _count_leading_scalars(key) -> int:+    """+    Number of leading MultiIndex levels pinned to a single label by ``key``.+    """+    if not isinstance(key, tuple):+        return int(is_scalar(key))+    count = 0+    for item in key:+        if not is_scalar(item):+            break+        count += 1+    return count++ def _tupleize_axis_indexer(ndim: int, axis: AxisInt, key) -> tuple[Any, ...]:     """     If we have an axis, adapt the given key to be axis-independent.
pandas/tests/indexing/multiindex/test_setitem.py163 + / 4 −
@@ -139,10 +139,9 @@ def test_multiindex_setitem(self):         df.loc[["bar"]] *= 2         tm.assert_frame_equal(df.loc[["bar"]], expected) -        # raise because these have differing levels-        msg = "cannot align on a multi-index with out specifying the join levels"-        with pytest.raises(TypeError, match=msg):-            df.loc["bar"] *= 2+        # GH#43719 the levels dropped by the partial key are restored to align+        df.loc["bar"] *= 2+        tm.assert_frame_equal(df.loc[["bar"]], expected * 2)      def test_multiindex_setitem2(self):         # from SO@@ -728,3 +727,163 @@ def test_iloc_setitem_unique_key_on_duplicated_multiindex_columns(     df.iloc[:, 0] = value     expected = pd.DataFrame(expected_values, columns=columns)     tm.assert_frame_equal(df, expected)+++@pytest.mark.parametrize("rows", [slice(None), [0, 1], np.array([True, False])])+@pytest.mark.parametrize("key", ["a", ("a",)])+@pytest.mark.parametrize("dtype", ["float32", "int64", "Int64"])+def test_loc_setitem_partial_column_key_frame_value(rows, key, dtype):+    # GH#43719 the value's columns lack the level matched by the partial key+    mi = pd.MultiIndex.from_tuples([("a", "x"), ("a", "y"), ("b", "x")])+    df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=mi, dtype=dtype)+    expected = df.copy()+    expected.loc[rows, ("a", slice(None))] = df.loc[:, ("a", slice(None))] * 10++    df.loc[rows, key] = df.loc[:, key] * 10+    tm.assert_frame_equal(df, expected)+++def test_loc_setitem_partial_column_key_frame_value_aligns():+    # GH#43719 the value is aligned by label, not position+    mi = pd.MultiIndex.from_tuples([("a", "x"), ("a", "y"), ("b", "x")])+    df = pd.DataFrame([[1.0, 2.0, 3.0]], columns=mi)+    df.loc[:, "a"] = df.loc[:, "a"][["y", "x"]] * 10+    expected = pd.DataFrame([[10.0, 20.0, 3.0]], columns=mi)+    tm.assert_frame_equal(df, expected)+++@pytest.mark.parametrize("key", ["a", ("a", "b")])+def test_loc_setitem_partial_column_key_three_levels(key):+    # GH#43719+    mi = pd.MultiIndex.from_tuples(+        [("a", "b", "x"), ("a", "b", "y"), ("a", "c", "x"), ("d", "b", "x")]+    )+    df = pd.DataFrame([[1.0, 2.0, 3.0, 4.0]], columns=mi)+    expected = df.copy()+    expected[key] = df[key] * 10++    df.loc[:, key] = df.loc[:, key] * 10+    tm.assert_frame_equal(df, expected)+++def test_loc_setitem_partial_row_key_value():+    # GH#43719 same for the rows of a DataFrame or Series+    mi = pd.MultiIndex.from_tuples([("a", "x"), ("a", "y"), ("b", "x")])+    df = pd.DataFrame({"A": [1.0, 2.0, 3.0], "B": [4.0, 5.0, 6.0]}, index=mi)+    expected = pd.DataFrame({"A": [10.0, 20.0, 3.0], "B": [4.0, 5.0, 6.0]}, index=mi)++    result = df.copy()+    result.loc["a"] = result.loc["a"] * [10, 1]+    tm.assert_frame_equal(result, expected)++    result = df.copy()+    result.loc["a", "A"] = result.loc["a", "A"] * 10+    tm.assert_frame_equal(result, expected)++    for key in ["a", ("a", slice(None))]:+        ser = df["A"].copy()+        ser.loc[key] = ser.loc[key] * 10+        tm.assert_series_equal(ser, expected["A"])+++def test_loc_setitem_partial_row_key_value_from_other_key():+    # GH#10440 the value is aligned on the levels not matched by the key+    mi = pd.MultiIndex.from_product([["p", "q"], ["x", "y"]])+    df = pd.DataFrame({"A": [1.0, 2.0, 3.0, 4.0]}, index=mi)+    df.loc["q", "A"] = df.loc["p", "A"][["y", "x"]]+    expected = pd.DataFrame({"A": [1.0, 2.0, 1.0, 2.0]}, index=mi)+    tm.assert_frame_equal(df, expected)+++def test_loc_setitem_partial_column_key_series_value():+    # GH#43719 a Series value aligned along the columns+    mi = pd.MultiIndex.from_tuples([("a", "x"), ("a", "y"), ("b", "x")])+    df = pd.DataFrame([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], columns=mi)+    df.loc[0, "a"] = df.loc[0, "a"] * 10+    expected = pd.DataFrame([[10.0, 20.0, 3.0], [4.0, 5.0, 6.0]], columns=mi)+    tm.assert_frame_equal(df, expected)+++def test_loc_setitem_scalar_and_list_column_key_series_value():+    # GH#15310+    mi = pd.MultiIndex.from_product([["a", "b"], ["x", "y"]])+    df = pd.DataFrame(np.zeros((2, 4)), columns=mi)+    df.loc[0, ("a", ["y", "x"])] = pd.Series({"x": 1.0, "y": 2.0})+    expected = pd.DataFrame([[1.0, 2.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]], columns=mi)+    tm.assert_frame_equal(df, expected)+++@pytest.mark.parametrize("dtype", ["string", "Int64", "datetime64[ns, UTC]"])+def test_loc_setitem_partial_column_key_frame_value_missing_labels(dtype):+    # GH#43719 labels missing from the value are filled with the dtype's NA+    mi = pd.MultiIndex.from_tuples([("a", "x"), ("a", "y"), ("b", "x")])+    df = pd.DataFrame([[1, 2, 3], [4, 5, 6]], columns=mi).astype(dtype)+    expected = df.copy()+    expected.loc[:, ("a", slice(None))] = df.loc[:, [("a", "x")]]++    df.loc[:, "a"] = df.loc[:, "a"][["x"]]+    tm.assert_frame_equal(df, expected)+++def test_loc_setitem_partial_row_key_value_not_relabeled():+    # GH#43719 a value whose labels are not the remaining levels is left alone+    mi = pd.MultiIndex.from_tuples(+        [("a", "b", "x"), ("a", "b", "y"), ("a", "c", "x"), ("d", "b", "x")]+    )+    ser = pd.Series([1.0, 2.0, 3.0, 4.0], index=mi)+    value = pd.Series([10.0, 20.0], index=mi[[0, 2]].droplevel(2))+    ser.loc["a"] = value+    expected = pd.Series([10.0, 10.0, 20.0, 4.0], index=mi)+    tm.assert_series_equal(ser, expected)++    ser = pd.Series([1.0, 2.0, 3.0], index=mi[[0, 1, 3]].droplevel(1))+    value = ser.loc[["a"]] * 10+    value.index = value.index.to_flat_index()+    ser.loc["a"] = value+    expected = pd.Series([10.0, 20.0, 3.0], index=ser.index)+    tm.assert_series_equal(ser, expected)+++@pytest.mark.parametrize("rows", [slice(None), [True, True]])+def test_loc_setitem_non_partial_key_value_not_relabeled(rows):+    # GH#43719 only the levels matched by a partial key are restored, so the+    #  value is not aligned on "x"/"y" even though every row is under "g"+    mi = pd.MultiIndex.from_product([["g"], ["x", "y"]])+    ser = pd.Series([1.0, 2.0], index=mi)+    ser.loc[rows] = pd.Series([10.0, 20.0], index=["x", "y"])+    expected = pd.Series([np.nan, np.nan], index=mi)+    tm.assert_series_equal(ser, expected)+++def test_loc_setitem_partial_column_key_with_enlargement():+    # GH#43719+    mi = pd.MultiIndex.from_tuples([("a", "x"), ("a", "y"), ("b", "x")])+    df = pd.DataFrame([[1.0, 2.0, 3.0]], columns=mi)+    df.loc[1, "a"] = df.loc[0, "a"] * 10+    expected = pd.DataFrame([[1.0, 2.0, 3.0], [10.0, 20.0, np.nan]], columns=mi)+    tm.assert_frame_equal(df, expected)+++def test_loc_setitem_partial_row_key_value_extra_labels():+    # GH#43719 value labels not under the key are ignored+    mi = pd.MultiIndex.from_tuples([("a", "x"), ("a", "y"), ("b", "x")])+    ser = pd.Series([1.0, 2.0, 3.0], index=mi)+    ser.loc["a"] = pd.Series({"x": 10.0, "z": 30.0})+    expected = pd.Series([10.0, np.nan, 3.0], index=mi)+    tm.assert_series_equal(ser, expected)+++@pytest.mark.parametrize(+    "first_level, key",+    [+        (pd.to_datetime(["2020-01-01", "2020-01-02", "2021-01-01"]), "2020"),+        (pd.IntervalIndex.from_tuples([(0, 2), (1, 3), (4, 5)]), 1.5),+    ],+)+def test_loc_setitem_partial_key_matching_several_labels(first_level, key):+    # GH#43719 the value is broadcast to each first-level label the key matches+    mi = pd.MultiIndex.from_product([first_level, ["x", "y"]])+    ser = pd.Series(np.arange(6.0), index=mi)+    ser.loc[key] = pd.Series({"x": 10.0, "y": 20.0})+    expected = pd.Series([10.0, 20.0, 10.0, 20.0, 4.0, 5.0], index=mi)+    tm.assert_series_equal(ser, expected)