Skip to content

Commit c5eea60

Browse files
authored
ENH: Styler.bar accepts matplotlib colormap (pandas-dev#43662)
1 parent 01b8d2a commit c5eea60

File tree

4 files changed

+114
-35
lines changed

4 files changed

+114
-35
lines changed

doc/source/user_guide/style.ipynb

+8-5
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"source": [
5050
"import pandas as pd\n",
5151
"import numpy as np\n",
52+
"import matplotlib as mpl\n",
5253
"\n",
5354
"df = pd.DataFrame([[38.0, 2.0, 18.0, 22.0, 21, np.nan],[19, 439, 6, 452, 226,232]], \n",
5455
" index=pd.Index(['Tumour (Positive)', 'Non-Tumour (Negative)'], name='Actual Label:'), \n",
@@ -1275,9 +1276,9 @@
12751276
"cell_type": "markdown",
12761277
"metadata": {},
12771278
"source": [
1278-
"Additional keyword arguments give more control on centering and positioning, and you can pass a list of `[color_negative, color_positive]` to highlight lower and higher values.\n",
1279+
"Additional keyword arguments give more control on centering and positioning, and you can pass a list of `[color_negative, color_positive]` to highlight lower and higher values or a matplotlib colormap.\n",
12791280
"\n",
1280-
"Here's how you can change the above with the new `align` option, combined with setting `vmin` and `vmax` limits, the `width` of the figure, and underlying css `props` of cells, leaving space to display the text and the bars:"
1281+
"To showcase an example here's how you can change the above with the new `align` option, combined with setting `vmin` and `vmax` limits, the `width` of the figure, and underlying css `props` of cells, leaving space to display the text and the bars. We also use `text_gradient` to color the text the same as the bars using a matplotlib colormap (although in this case the visualization is probably better without this additional effect)."
12811282
]
12821283
},
12831284
{
@@ -1286,8 +1287,10 @@
12861287
"metadata": {},
12871288
"outputs": [],
12881289
"source": [
1289-
"df2.style.bar(align=0, vmin=-2.5, vmax=2.5, color=['#d65f5f', '#5fba7d'], height=50,\n",
1290-
" width=60, props=\"width: 120px; border-right: 1px solid black;\").format('{:.3f}', na_rep=\"\")"
1290+
"df2.style.format('{:.3f}', na_rep=\"\")\\\n",
1291+
" .bar(align=0, vmin=-2.5, vmax=2.5, color=mpl.cm.get_cmap(\"bwr\"), height=50,\n",
1292+
" width=60, props=\"width: 120px; border-right: 1px solid black;\")\\\n",
1293+
" .text_gradient(cmap=\"bwr\", vmin=-2.5, vmax=2.5)"
12911294
]
12921295
},
12931296
{
@@ -2031,7 +2034,7 @@
20312034
"name": "python",
20322035
"nbconvert_exporter": "python",
20332036
"pygments_lexer": "ipython3",
2034-
"version": "3.8.7"
2037+
"version": "3.8.6"
20352038
}
20362039
},
20372040
"nbformat": 4,

doc/source/whatsnew/v1.4.0.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Styler
7373
:class:`.Styler` has been further developed in 1.4.0. The following enhancements have been made:
7474

7575
- Styling and formatting of indexes has been added, with :meth:`.Styler.apply_index`, :meth:`.Styler.applymap_index` and :meth:`.Styler.format_index`. These mirror the signature of the methods already used to style and format data values, and work with both HTML and LaTeX format (:issue:`41893`, :issue:`43101`).
76-
- :meth:`.Styler.bar` introduces additional arguments to control alignment and display (:issue:`26070`, :issue:`36419`), and it also validates the input arguments ``width`` and ``height`` (:issue:`42511`).
76+
- :meth:`.Styler.bar` introduces additional arguments to control alignment, display and colors (:issue:`26070`, :issue:`36419`, :issue:`43662`), and it also validates the input arguments ``width`` and ``height`` (:issue:`42511`).
7777
- :meth:`.Styler.to_latex` introduces keyword argument ``environment``, which also allows a specific "longtable" entry through a separate jinja2 template (:issue:`41866`).
7878
- :meth:`.Styler.to_html` introduces keyword arguments ``sparse_index``, ``sparse_columns``, ``bold_headers``, ``caption``, ``max_rows`` and ``max_columns`` (:issue:`41946`, :issue:`43149`, :issue:`42972`).
7979
- Keyword arguments ``level`` and ``names`` added to :meth:`.Styler.hide_index` and :meth:`.Styler.hide_columns` for additional control of visibility of MultiIndexes and index names (:issue:`25475`, :issue:`43404`, :issue:`43346`)

pandas/io/formats/style.py

+77-29
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
IndexSlice,
3535
RangeIndex,
3636
)
37-
from pandas.api.types import is_list_like
3837
from pandas.core import generic
3938
import pandas.core.common as com
4039
from pandas.core.frame import (
@@ -60,7 +59,7 @@
6059
)
6160

6261
try:
63-
from matplotlib import colors
62+
import matplotlib as mpl
6463
import matplotlib.pyplot as plt
6564

6665
has_mpl = True
@@ -72,7 +71,7 @@
7271
@contextmanager
7372
def _mpl(func: Callable):
7473
if has_mpl:
75-
yield plt, colors
74+
yield plt, mpl
7675
else:
7776
raise ImportError(no_mpl_message.format(func.__name__))
7877

@@ -2608,7 +2607,8 @@ def bar(
26082607
subset: Subset | None = None,
26092608
axis: Axis | None = 0,
26102609
*,
2611-
color="#d65f5f",
2610+
color: str | list | tuple | None = None,
2611+
cmap: Any | None = None,
26122612
width: float = 100,
26132613
height: float = 100,
26142614
align: str | float | int | Callable = "mid",
@@ -2636,6 +2636,11 @@ def bar(
26362636
negative and positive numbers. If 2-tuple/list is used, the
26372637
first element is the color_negative and the second is the
26382638
color_positive (eg: ['#d65f5f', '#5fba7d']).
2639+
cmap : str, matplotlib.cm.ColorMap
2640+
A string name of a matplotlib Colormap, or a Colormap object. Cannot be
2641+
used together with ``color``.
2642+
2643+
.. versionadded:: 1.4.0
26392644
width : float, default 100
26402645
The percentage of the cell, measured from the left, in which to draw the
26412646
bars, in [0, 100].
@@ -2678,17 +2683,25 @@ def bar(
26782683
Returns
26792684
-------
26802685
self : Styler
2681-
"""
2682-
if not (is_list_like(color)):
2683-
color = [color, color]
2684-
elif len(color) == 1:
2685-
color = [color[0], color[0]]
2686-
elif len(color) > 2:
2687-
raise ValueError(
2688-
"`color` must be string or a list-like "
2689-
"of length 2: [`color_neg`, `color_pos`] "
2690-
"(eg: color=['#d65f5f', '#5fba7d'])"
2691-
)
2686+
2687+
Notes
2688+
-----
2689+
This section of the user guide:
2690+
`Table Visualization <../../user_guide/style.ipynb>`_ gives
2691+
a number of examples for different settings and color coordination.
2692+
"""
2693+
if color is None and cmap is None:
2694+
color = "#d65f5f"
2695+
elif color is not None and cmap is not None:
2696+
raise ValueError("`color` and `cmap` cannot both be given")
2697+
elif color is not None:
2698+
if (isinstance(color, (list, tuple)) and len(color) > 2) or not isinstance(
2699+
color, (str, list, tuple)
2700+
):
2701+
raise ValueError(
2702+
"`color` must be string or list or tuple of 2 strings,"
2703+
"(eg: color=['#d65f5f', '#5fba7d'])"
2704+
)
26922705

26932706
if not (0 <= width <= 100):
26942707
raise ValueError(f"`width` must be a value in [0, 100], got {width}")
@@ -2704,6 +2717,7 @@ def bar(
27042717
axis=axis,
27052718
align=align,
27062719
colors=color,
2720+
cmap=cmap,
27072721
width=width / 100,
27082722
height=height / 100,
27092723
vmin=vmin,
@@ -3260,12 +3274,12 @@ def _background_gradient(
32603274
else: # else validate gmap against the underlying data
32613275
gmap = _validate_apply_axis_arg(gmap, "gmap", float, data)
32623276

3263-
with _mpl(Styler.background_gradient) as (plt, colors):
3277+
with _mpl(Styler.background_gradient) as (plt, mpl):
32643278
smin = np.nanmin(gmap) if vmin is None else vmin
32653279
smax = np.nanmax(gmap) if vmax is None else vmax
32663280
rng = smax - smin
32673281
# extend lower / upper bounds, compresses color range
3268-
norm = colors.Normalize(smin - (rng * low), smax + (rng * high))
3282+
norm = mpl.colors.Normalize(smin - (rng * low), smax + (rng * high))
32693283
rgbas = plt.cm.get_cmap(cmap)(norm(gmap))
32703284

32713285
def relative_luminance(rgba) -> float:
@@ -3294,9 +3308,11 @@ def css(rgba, text_only) -> str:
32943308
if not text_only:
32953309
dark = relative_luminance(rgba) < text_color_threshold
32963310
text_color = "#f1f1f1" if dark else "#000000"
3297-
return f"background-color: {colors.rgb2hex(rgba)};color: {text_color};"
3311+
return (
3312+
f"background-color: {mpl.colors.rgb2hex(rgba)};color: {text_color};"
3313+
)
32983314
else:
3299-
return f"color: {colors.rgb2hex(rgba)};"
3315+
return f"color: {mpl.colors.rgb2hex(rgba)};"
33003316

33013317
if data.ndim == 1:
33023318
return [css(rgba, text_only) for rgba in rgbas]
@@ -3369,7 +3385,8 @@ def _highlight_value(data: DataFrame | Series, op: str, props: str) -> np.ndarra
33693385
def _bar(
33703386
data: NDFrame,
33713387
align: str | float | int | Callable,
3372-
colors: list[str],
3388+
colors: str | list | tuple,
3389+
cmap: Any,
33733390
width: float,
33743391
height: float,
33753392
vmin: float | None,
@@ -3431,7 +3448,7 @@ def css_bar(start: float, end: float, color: str) -> str:
34313448
cell_css += f" {color} {end*100:.1f}%, transparent {end*100:.1f}%)"
34323449
return cell_css
34333450

3434-
def css_calc(x, left: float, right: float, align: str):
3451+
def css_calc(x, left: float, right: float, align: str, color: str | list | tuple):
34353452
"""
34363453
Return the correct CSS for bar placement based on calculated values.
34373454
@@ -3462,7 +3479,10 @@ def css_calc(x, left: float, right: float, align: str):
34623479
if pd.isna(x):
34633480
return base_css
34643481

3465-
color = colors[0] if x < 0 else colors[1]
3482+
if isinstance(color, (list, tuple)):
3483+
color = color[0] if x < 0 else color[1]
3484+
assert isinstance(color, str) # mypy redefinition
3485+
34663486
x = left if x < left else x
34673487
x = right if x > right else x # trim data if outside of the window
34683488

@@ -3525,15 +3545,43 @@ def css_calc(x, left: float, right: float, align: str):
35253545
"value defining the center line or a callable that returns a float"
35263546
)
35273547

3548+
rgbas = None
3549+
if cmap is not None:
3550+
# use the matplotlib colormap input
3551+
with _mpl(Styler.bar) as (plt, mpl):
3552+
cmap = (
3553+
mpl.cm.get_cmap(cmap)
3554+
if isinstance(cmap, str)
3555+
else cmap # assumed to be a Colormap instance as documented
3556+
)
3557+
norm = mpl.colors.Normalize(left, right)
3558+
rgbas = cmap(norm(values))
3559+
if data.ndim == 1:
3560+
rgbas = [mpl.colors.rgb2hex(rgba) for rgba in rgbas]
3561+
else:
3562+
rgbas = [[mpl.colors.rgb2hex(rgba) for rgba in row] for row in rgbas]
3563+
35283564
assert isinstance(align, str) # mypy: should now be in [left, right, mid, zero]
35293565
if data.ndim == 1:
3530-
return [css_calc(x - z, left - z, right - z, align) for x in values]
3566+
return [
3567+
css_calc(
3568+
x - z, left - z, right - z, align, colors if rgbas is None else rgbas[i]
3569+
)
3570+
for i, x in enumerate(values)
3571+
]
35313572
else:
3532-
return DataFrame(
3573+
return np.array(
35333574
[
3534-
[css_calc(x - z, left - z, right - z, align) for x in row]
3535-
for row in values
3536-
],
3537-
index=data.index,
3538-
columns=data.columns,
3575+
[
3576+
css_calc(
3577+
x - z,
3578+
left - z,
3579+
right - z,
3580+
align,
3581+
colors if rgbas is None else rgbas[i][j],
3582+
)
3583+
for j, x in enumerate(row)
3584+
]
3585+
for i, row in enumerate(values)
3586+
]
35393587
)

pandas/tests/io/formats/style/test_matplotlib.py

+28
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
pytest.importorskip("matplotlib")
1111
pytest.importorskip("jinja2")
1212

13+
import matplotlib as mpl
14+
1315
from pandas.io.formats.style import Styler
1416

1517

@@ -256,3 +258,29 @@ def test_background_gradient_gmap_wrong_series(styler_blank):
256258
gmap = Series([1, 2], index=["X", "Y"])
257259
with pytest.raises(ValueError, match=msg):
258260
styler_blank.background_gradient(gmap=gmap, axis=None)._compute()
261+
262+
263+
@pytest.mark.parametrize("cmap", ["PuBu", mpl.cm.get_cmap("PuBu")])
264+
def test_bar_colormap(cmap):
265+
data = DataFrame([[1, 2], [3, 4]])
266+
ctx = data.style.bar(cmap=cmap, axis=None)._compute().ctx
267+
pubu_colors = {
268+
(0, 0): "#d0d1e6",
269+
(1, 0): "#056faf",
270+
(0, 1): "#73a9cf",
271+
(1, 1): "#023858",
272+
}
273+
for k, v in pubu_colors.items():
274+
assert v in ctx[k][1][1]
275+
276+
277+
def test_bar_color_raises(df):
278+
msg = "`color` must be string or list or tuple of 2 strings"
279+
with pytest.raises(ValueError, match=msg):
280+
df.style.bar(color={"a", "b"}).to_html()
281+
with pytest.raises(ValueError, match=msg):
282+
df.style.bar(color=["a", "b", "c"]).to_html()
283+
284+
msg = "`color` and `cmap` cannot both be given"
285+
with pytest.raises(ValueError, match=msg):
286+
df.style.bar(color="something", cmap="something else").to_html()

0 commit comments

Comments
 (0)