Repository: pandas-dev/pandas
Pull request: ENH: add kw `levels` to `Styler.hide_index` to hide only specific levels
- [x] closes #25475
- [x] tests added / passed
- [x] whatsnew entry 1.4.0

![Screen Shot 2021-07-21 at 14 13 35](https://user-images.githubusercontent.com/24256554/126486650-af383d0b-3372-4c19-bb4a-db19dff25e6f.png)


Problem statement:
New Feature Request: df.style.hide_index() to hide a particular index level

#### Problem description

The present implementation of `df.style.hide_index()` removes the complete index while rendering html. 
It would be better to have an optional input of index level and hide only that index level while rendering.
example  `df.style.hide_index(level=1)`
It provides more option and flexibility to user. 

 Sorry to post this as an issue, but I didn't find any option for suggesting new features.

Hints from issue discussion:
> Sorry to post this as an issue, but I didn't find any option for suggesting new features.

An issue is fine for feature requests, but we need a simple example what the request is.
```
In[15]: df
Out[15]: 
                0         1         2         3
bar1 one  0.624257 -1.919695 -0.608513  1.460487
bar2 two -0.609755  0.874438  0.169402 -1.236653
baz1 one  0.694922  0.585993 -0.871894 -0.240299
baz2 two -0.158941 -1.604056 -0.517174  0.551289
foo1 one -1.606837  1.009960 -1.026319  1.377517
foo2 two -1.062810 -0.041552 -0.423098  0.438010
qux1 one  1.082581  0.182992 -1.910832  0.156215
foo2 two -0.255548  0.419918 -0.668972 -0.586820
```
Now doing df.style.hide_index().set_table_attributes('border=1').render() and opening in browser gives 
`https://www.w3schools.com/code/tryit.asp?filename=G1MGWU5COD4H`. 
Open the link and press run.

I want that there should be an option for level in hide_index() like 
`df.style.hide_index(level=1).set_table_attributes('border=1').render()` which gives output like 
`https://www.w3schools.com/code/tryit.asp?filename=G1MH0SRAWPMS`
Open the link and press run.
Thanks. Note that MultiIndex support in `.style` is pretty limited right now. It doesn't look especially easy to add this to the current implementation.
Okay. Just thought that it would be a good to have feature in pandas.style .
Thanks
Indeed. Let us know if you're interested in working on it.

On Thu, Feb 28, 2019 at 8:01 AM sambhavcha <notifications@github.com> wrote:

> Okay. Just thought that it would be a good to have feature in pandas.style
> .
> Thanks
>
> —
> You are receiving this because you commented.
> Reply to this email directly, view it on GitHub
> <https://github.com/pandas-dev/pandas/issues/25475#issuecomment-468282791>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/ABQHIiCmP1M-XXSp_0sLsl9P_p7rGlwUks5vR-EngaJpZM4bWvif>
> .
>

Yeah of course i would love to work on it. And I would also like to know more about the implementations of pandas too. 
OK, it's all at
https://github.com/pandas-dev/pandas/blob/master/pandas/io/formats/style.py
and
http://pandas-docs.github.io/pandas-docs-travis/development/contributing.html
has the contributing guide. Let us know where you get stuck.

On Thu, Feb 28, 2019 at 8:11 AM sambhavcha <notifications@github.com> wrote:

> Yeah of course i would love to work on it. And I would also like to know
> more about the implementations of pandas too.
>
> —
> You are receiving this because you commented.
> Reply to this email directly, view it on GitHub
> <https://github.com/pandas-dev/pandas/issues/25475#issuecomment-468286068>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/ABQHIv15rXTtyhjVlS3dK4DIQtrzGx8Xks5vR-N6gaJpZM4bWvif>
> .
>

Great. Thanks for the quick response.

Commit message: Merge remote-tracking branch 'upstream/master' into hide_index_levels

Patch to review:
diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst
index 08f30f467dfa7..629580b673f1c 100644
--- a/doc/source/whatsnew/v1.4.0.rst
+++ b/doc/source/whatsnew/v1.4.0.rst
@@ -33,6 +33,7 @@ Other enhancements
 - :meth:`Series.sample`, :meth:`DataFrame.sample`, and :meth:`.GroupBy.sample` now accept a ``np.random.Generator`` as input to ``random_state``. A generator will be more performant, especially with ``replace=False`` (:issue:`38100`)
 -  Additional options added to :meth:`.Styler.bar` to control alignment and display, with keyword only arguments (:issue:`26070`, :issue:`36419`)
 - :meth:`Styler.bar` now validates the input argument ``width`` and ``height`` (:issue:`42511`)
+- Add keyword ``levels`` to :meth:`.Styler.hide_index` for optionally controlling hidden levels in a MultiIndex (:issue:`25475`)
 - :meth:`Series.ewm`, :meth:`DataFrame.ewm`, now support a ``method`` argument with a ``'table'`` option that performs the windowing operation over an entire :class:`DataFrame`. See :ref:`Window Overview <window.overview>` for performance and functional benefits (:issue:`42273`)
 -
 
diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py
index 91a301b665f7c..19f9a7d898da4 100644
--- a/pandas/io/formats/style.py
+++ b/pandas/io/formats/style.py
@@ -730,7 +730,9 @@ def to_latex(
             self.data.columns = RangeIndex(stop=len(self.data.columns))
             numeric_cols = self.data._get_numeric_data().columns.to_list()
             self.data.columns = _original_columns
-            column_format = "" if self.hide_index_ else "l" * self.data.index.nlevels
+            column_format = ""
+            for level in range(self.index.nlevels):
+                column_format += "" if self.hide_index_[level] else "l"
             for ci, _ in enumerate(self.data.columns):
                 if ci not in self.hidden_columns:
                     column_format += (
@@ -1683,14 +1685,18 @@ def set_na_rep(self, na_rep: str) -> StylerRenderer:
         self.na_rep = na_rep
         return self.format(na_rep=na_rep, precision=self.precision)
 
-    def hide_index(self, subset: Subset | None = None) -> Styler:
+    def hide_index(
+        self,
+        subset: Subset | None = None,
+        levels: int | str | list[int | str] | None = None,
+    ) -> Styler:
         """
         Hide the entire index, or specific keys in the index from rendering.
 
         This method has dual functionality:
 
-          - if ``subset`` is ``None`` then the entire index will be hidden whilst
-            displaying all data-rows.
+          - if ``subset`` is ``None`` then the entire index, or specified levels, will
+            be hidden whilst displaying all data-rows.
           - if a ``subset`` is given then those specific rows will be hidden whilst the
             index itself remains visible.
 
@@ -1702,6 +1708,9 @@ def hide_index(self, subset: Subset | None = None) -> Styler:
             A valid 1d input or single key along the index axis within
             `DataFrame.loc[<subset>, :]`, to limit ``data`` to *before* applying
             the function.
+        levels : int, str, list
+            The level(s) to hide in a MultiIndex if hiding the entire index. Cannot be
+            used simultaneously with ``subset``.
 
         Returns
         -------
@@ -1751,8 +1760,28 @@ def hide_index(self, subset: Subset | None = None) -> Styler:
          0.7    1.0    1.3    1.5   -0.0   -0.2
         -0.6    1.2    1.8    1.9    0.3    0.3
         """
+        if levels is not None and subset is not None:
+            raise ValueError("`subset` and `levels` cannot be passed simultaneously")
+
         if subset is None:
-            self.hide_index_ = True
+            if levels is None:
+                levels_: list[int] = list(range(self.index.nlevels))
+            elif isinstance(levels, int):
+                levels_ = [levels]
+            elif isinstance(levels, str):
+                levels_ = [self.index._get_level_number(levels)]
+            elif isinstance(levels, list):
+                levels_ = [
+                    self.index._get_level_number(lev) if isinstance(lev, str) else lev
+                    for lev in levels
+                ]
+            else:
+                raise ValueError(
+                    "`levels` must be of type `int`, `str` or list of such"
+                )
+            self.hide_index_ = [
+                True if lev in levels_ else False for lev in range(self.index.nlevels)
+            ]
         else:
             subset_ = IndexSlice[subset, :]  # new var so mypy reads not Optional
             subset = non_reducing_slice(subset_)
diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py
index c855caa3c3ee0..7f43a36b3955a 100644
--- a/pandas/io/formats/style_render.py
+++ b/pandas/io/formats/style_render.py
@@ -97,7 +97,7 @@ def __init__(
         self.cell_ids = cell_ids
 
         # add rendering variables
-        self.hide_index_: bool = False  # bools for hiding col/row headers
+        self.hide_index_: list = [False] * self.index.nlevels
         self.hide_columns_: bool = False
         self.hidden_rows: Sequence[int] = []  # sequence for specific hidden rows/cols
         self.hidden_columns: Sequence[int] = []
@@ -305,9 +305,10 @@ def _translate_header(
         # 1) column headers
         if not self.hide_columns_:
             for r in range(self.data.columns.nlevels):
-                index_blanks = [
-                    _element("th", blank_class, blank_value, not self.hide_index_)
-                ] * (self.data.index.nlevels - 1)
+                # number of index blanks is governed by number of hidden index levels
+                index_blanks = [_element("th", blank_class, blank_value, True)] * (
+                    self.index.nlevels - sum(self.hide_index_) - 1
+                )
 
                 name = self.data.columns.names[r]
                 column_name = [
@@ -315,7 +316,7 @@ def _translate_header(
                         "th",
                         f"{blank_class if name is None else index_name_class} level{r}",
                         name if name is not None else blank_value,
-                        not self.hide_index_,
+                        not all(self.hide_index_),
                     )
                 ]
 
@@ -352,14 +353,14 @@ def _translate_header(
         if (
             self.data.index.names
             and com.any_not_none(*self.data.index.names)
-            and not self.hide_index_
+            and not all(self.hide_index_)
         ):
             index_names = [
                 _element(
                     "th",
                     f"{index_name_class} level{c}",
                     blank_value if name is None else name,
-                    True,
+                    not self.hide_index_[c],
                 )
                 for c, name in enumerate(self.data.index.names)
             ]
@@ -434,7 +435,7 @@ def _translate_body(
                         "th",
                         f"{row_heading_class} level{c} {trimmed_row_class}",
                         "...",
-                        not self.hide_index_,
+                        not self.hide_index_[c],
                         attributes="",
                     )
                     for c in range(self.data.index.nlevels)
@@ -471,7 +472,7 @@ def _translate_body(
                     "th",
                     f"{row_heading_class} level{c} row{r}",
                     value,
-                    (_is_visible(r, c, idx_lengths) and not self.hide_index_),
+                    (_is_visible(r, c, idx_lengths) and not self.hide_index_[c]),
                     id=f"level{c}_row{r}",
                     attributes=(
                         f'rowspan="{idx_lengths.get((c, r), 0)}"'
@@ -536,7 +537,7 @@ def _translate_latex(self, d: dict) -> None:
         d["head"] = [[col for col in row if col["is_visible"]] for row in d["head"]]
         body = []
         for r, row in enumerate(d["body"]):
-            if self.hide_index_:
+            if all(self.hide_index_):
                 row_body_headers = []
             else:
                 row_body_headers = [
diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py
index 64c62a00ff29d..ac9ffcfc3cb5c 100644
--- a/pandas/tests/io/formats/style/test_style.py
+++ b/pandas/tests/io/formats/style/test_style.py
@@ -260,6 +260,32 @@ def test_clear(mi_styler_comp):
         assert all(res) if hasattr(res, "__iter__") else res
 
 
+def test_hide_raises(mi_styler):
+    msg = "`subset` and `levels` cannot be passed simultaneously"
+    with pytest.raises(ValueError, match=msg):
+        mi_styler.hide_index(subset="something", levels="something else")
+
+    msg = "`levels` must be of type `int`, `str` or list of such"
+    with pytest.raises(ValueError, match=msg):
+        mi_styler.hide_index(levels=("bad", "type"))
+
+
+@pytest.mark.parametrize("levels", [1, "one", [1], ["one"]])
+def test_hide_level(mi_styler, levels):
+    mi_styler.index.names, mi_styler.columns.names = ["zero", "one"], ["zero", "one"]
+    ctx = mi_styler.hide_index(levels=levels)._translate(False, True)
+    assert len(ctx["head"][0]) == 3
+    assert len(ctx["head"][1]) == 3
+    assert len(ctx["head"][2]) == 4
+    assert ctx["head"][2][0]["is_visible"]
+    assert not ctx["head"][2][1]["is_visible"]
+
+    assert ctx["body"][0][0]["is_visible"]
+    assert not ctx["body"][0][1]["is_visible"]
+    assert ctx["body"][1][0]["is_visible"]
+    assert not ctx["body"][1][1]["is_visible"]
+
+
 class TestStyler:
     def setup_method(self, method):
         np.random.seed(24)
@@ -1157,7 +1183,7 @@ def test_hide_single_index(self):
     def test_hide_multiindex(self):
         # GH 14194
         df = DataFrame(
-            {"A": [1, 2]},
+            {"A": [1, 2], "B": [1, 2]},
             index=MultiIndex.from_arrays(
                 [["a", "a"], [0, 1]], names=["idx_level_0", "idx_level_1"]
             ),
@@ -1167,16 +1193,17 @@ def test_hide_multiindex(self):
         assert ctx1["body"][0][0]["is_visible"]
         assert ctx1["body"][0][1]["is_visible"]
         # check for blank header rows
-        assert ctx1["head"][0][0]["is_visible"]
-        assert ctx1["head"][0][1]["is_visible"]
+        print(ctx1["head"][0])
+        assert len(ctx1["head"][0]) == 4  # two visible indexes and two data columns
 
         ctx2 = df.style.hide_index()._translate(True, True)
         # tests for 'a' and '0'
         assert not ctx2["body"][0][0]["is_visible"]
         assert not ctx2["body"][0][1]["is_visible"]
         # check for blank header rows
+        print(ctx2["head"][0])
+        assert len(ctx2["head"][0]) == 3  # one hidden (col name) and two data columns
         assert not ctx2["head"][0][0]["is_visible"]
-        assert not ctx2["head"][0][1]["is_visible"]
 
     def test_hide_columns_single_level(self):
         # GH 14194
@@ -1243,9 +1270,10 @@ def test_hide_columns_index_mult_levels(self):
         # hide second column and index
         ctx = df.style.hide_columns([("b", 1)]).hide_index()._translate(True, True)
         assert not ctx["body"][0][0]["is_visible"]  # index
-        assert ctx["head"][0][2]["is_visible"]  # b
-        assert ctx["head"][1][2]["is_visible"]  # 0
-        assert not ctx["head"][1][3]["is_visible"]  # 1
+        assert len(ctx["head"][0]) == 3
+        assert ctx["head"][0][1]["is_visible"]  # b
+        assert ctx["head"][1][1]["is_visible"]  # 0
+        assert not ctx["head"][1][2]["is_visible"]  # 1
         assert not ctx["body"][1][3]["is_visible"]  # 4
         assert ctx["body"][1][2]["is_visible"]
         assert ctx["body"][1][2]["display_value"] == 3


Code review comments:
diff_hunk: @@ -1683,14 +1685,18 @@ def set_na_rep(self, na_rep: str) -> StylerRenderer:
         self.na_rep = na_rep
         return self.format(na_rep=na_rep, precision=self.precision)
 
-    def hide_index(self, subset: Subset | None = None) -> Styler:
+    def hide_index(
+        self,
+        subset: Subset | None = None,
+        levels: int | str | list[int | str] | None = None,
path: pandas/io/formats/style.py
text: @user1:
for followon, I believe we have Level to describe this typing.

@author:
changed, please review.