Skip to content

Commit 5e6cb1f

Browse files
authored
ENH: add a gradient map to background gradient (#39930)
1 parent 13d651d commit 5e6cb1f

File tree

9 files changed

+339
-96
lines changed

9 files changed

+339
-96
lines changed

doc/source/_static/style/bg_ax0.png

13.4 KB
Loading
13.9 KB
Loading
13.3 KB
Loading
13.7 KB
Loading
12.7 KB
Loading

doc/source/_static/style/bg_gmap.png

12.6 KB
Loading

doc/source/whatsnew/v1.3.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ Other enhancements
186186
- :meth:`.Styler.apply` now more consistently accepts ndarray function returns, i.e. in all cases for ``axis`` is ``0, 1 or None`` (:issue:`39359`)
187187
- :meth:`.Styler.apply` and :meth:`.Styler.applymap` now raise errors if wrong format CSS is passed on render (:issue:`39660`)
188188
- :meth:`.Styler.format` adds keyword argument ``escape`` for optional HTML escaping (:issue:`40437`)
189+
- :meth:`.Styler.background_gradient` now allows the ability to supply a specific gradient map (:issue:`22727`)
189190
- :meth:`.Styler.clear` now clears :attr:`Styler.hidden_index` and :attr:`Styler.hidden_columns` as well (:issue:`40484`)
190191
- Builtin highlighting methods in :class:`Styler` have a more consistent signature and css customisability (:issue:`40242`)
191192
- :meth:`Series.loc.__getitem__` and :meth:`Series.loc.__setitem__` with :class:`MultiIndex` now raising helpful error message when indexer has too many dimensions (:issue:`35349`)

pandas/io/formats/style.py

+210-87
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@
4343
from pandas.api.types import is_list_like
4444
from pandas.core import generic
4545
import pandas.core.common as com
46-
from pandas.core.frame import DataFrame
46+
from pandas.core.frame import (
47+
DataFrame,
48+
Series,
49+
)
4750
from pandas.core.generic import NDFrame
4851
from pandas.core.indexes.api import Index
4952

@@ -179,7 +182,7 @@ def __init__(
179182
escape: bool = False,
180183
):
181184
# validate ordered args
182-
if isinstance(data, pd.Series):
185+
if isinstance(data, Series):
183186
data = data.to_frame()
184187
if not isinstance(data, DataFrame):
185188
raise TypeError("``data`` must be a Series or DataFrame")
@@ -1438,67 +1441,136 @@ def background_gradient(
14381441
text_color_threshold: float = 0.408,
14391442
vmin: float | None = None,
14401443
vmax: float | None = None,
1444+
gmap: Sequence | None = None,
14411445
) -> Styler:
14421446
"""
14431447
Color the background in a gradient style.
14441448
14451449
The background color is determined according
1446-
to the data in each column (optionally row). Requires matplotlib.
1450+
to the data in each column, row or frame, or by a given
1451+
gradient map. Requires matplotlib.
14471452
14481453
Parameters
14491454
----------
14501455
cmap : str or colormap
14511456
Matplotlib colormap.
14521457
low : float
1453-
Compress the range by the low.
1458+
Compress the color range at the low end. This is a multiple of the data
1459+
range to extend below the minimum; good values usually in [0, 1],
1460+
defaults to 0.
14541461
high : float
1455-
Compress the range by the high.
1462+
Compress the color range at the high end. This is a multiple of the data
1463+
range to extend above the maximum; good values usually in [0, 1],
1464+
defaults to 0.
14561465
axis : {0 or 'index', 1 or 'columns', None}, default 0
14571466
Apply to each column (``axis=0`` or ``'index'``), to each row
14581467
(``axis=1`` or ``'columns'``), or to the entire DataFrame at once
14591468
with ``axis=None``.
14601469
subset : IndexSlice
14611470
A valid slice for ``data`` to limit the style application to.
14621471
text_color_threshold : float or int
1463-
Luminance threshold for determining text color. Facilitates text
1464-
visibility across varying background colors. From 0 to 1.
1465-
0 = all text is dark colored, 1 = all text is light colored.
1472+
Luminance threshold for determining text color in [0, 1]. Facilitates text
1473+
visibility across varying background colors. All text is dark if 0, and
1474+
light if 1, defaults to 0.408.
14661475
14671476
.. versionadded:: 0.24.0
14681477
14691478
vmin : float, optional
14701479
Minimum data value that corresponds to colormap minimum value.
1471-
When None (default): the minimum value of the data will be used.
1480+
If not specified the minimum value of the data (or gmap) will be used.
14721481
14731482
.. versionadded:: 1.0.0
14741483
14751484
vmax : float, optional
14761485
Maximum data value that corresponds to colormap maximum value.
1477-
When None (default): the maximum value of the data will be used.
1486+
If not specified the maximum value of the data (or gmap) will be used.
14781487
14791488
.. versionadded:: 1.0.0
14801489
1490+
gmap : array-like, optional
1491+
Gradient map for determining the background colors. If not supplied
1492+
will use the underlying data from rows, columns or frame. If given as an
1493+
ndarray or list-like must be an identical shape to the underlying data
1494+
considering ``axis`` and ``subset``. If given as DataFrame or Series must
1495+
have same index and column labels considering ``axis`` and ``subset``.
1496+
If supplied, ``vmin`` and ``vmax`` should be given relative to this
1497+
gradient map.
1498+
1499+
.. versionadded:: 1.3.0
1500+
14811501
Returns
14821502
-------
14831503
self : Styler
14841504
1485-
Raises
1486-
------
1487-
ValueError
1488-
If ``text_color_threshold`` is not a value from 0 to 1.
1489-
14901505
Notes
14911506
-----
1492-
Set ``text_color_threshold`` or tune ``low`` and ``high`` to keep the
1493-
text legible by not using the entire range of the color map. The range
1494-
of the data is extended by ``low * (x.max() - x.min())`` and ``high *
1495-
(x.max() - x.min())`` before normalizing.
1507+
When using ``low`` and ``high`` the range
1508+
of the gradient, given by the data if ``gmap`` is not given or by ``gmap``,
1509+
is extended at the low end effectively by
1510+
`map.min - low * map.range` and at the high end by
1511+
`map.max + high * map.range` before the colors are normalized and determined.
1512+
1513+
If combining with ``vmin`` and ``vmax`` the `map.min`, `map.max` and
1514+
`map.range` are replaced by values according to the values derived from
1515+
``vmin`` and ``vmax``.
1516+
1517+
This method will preselect numeric columns and ignore non-numeric columns
1518+
unless a ``gmap`` is supplied in which case no preselection occurs.
1519+
1520+
Examples
1521+
--------
1522+
>>> df = pd.DataFrame({
1523+
... 'City': ['Stockholm', 'Oslo', 'Copenhagen'],
1524+
... 'Temp (c)': [21.6, 22.4, 24.5],
1525+
... 'Rain (mm)': [5.0, 13.3, 0.0],
1526+
... 'Wind (m/s)': [3.2, 3.1, 6.7]
1527+
... })
1528+
1529+
Shading the values column-wise, with ``axis=0``, preselecting numeric columns
1530+
1531+
>>> df.style.background_gradient(axis=0)
1532+
1533+
.. figure:: ../../_static/style/bg_ax0.png
1534+
1535+
Shading all values collectively using ``axis=None``
1536+
1537+
>>> df.style.background_gradient(axis=None)
1538+
1539+
.. figure:: ../../_static/style/bg_axNone.png
1540+
1541+
Compress the color map from the both ``low`` and ``high`` ends
1542+
1543+
>>> df.style.background_gradient(axis=None, low=0.75, high=1.0)
1544+
1545+
.. figure:: ../../_static/style/bg_axNone_lowhigh.png
1546+
1547+
Manually setting ``vmin`` and ``vmax`` gradient thresholds
1548+
1549+
>>> df.style.background_gradient(axis=None, vmin=6.7, vmax=21.6)
1550+
1551+
.. figure:: ../../_static/style/bg_axNone_vminvmax.png
1552+
1553+
Setting a ``gmap`` and applying to all columns with another ``cmap``
1554+
1555+
>>> df.style.background_gradient(axis=0, gmap=df['Temp (c)'], cmap='YlOrRd')
1556+
1557+
.. figure:: ../../_static/style/bg_gmap.png
1558+
1559+
Setting the gradient map for a dataframe (i.e. ``axis=None``), we need to
1560+
explicitly state ``subset`` to match the ``gmap`` shape
1561+
1562+
>>> gmap = np.array([[1,2,3], [2,3,4], [3,4,5]])
1563+
>>> df.style.background_gradient(axis=None, gmap=gmap,
1564+
... cmap='YlOrRd', subset=['Temp (c)', 'Rain (mm)', 'Wind (m/s)']
1565+
... )
1566+
1567+
.. figure:: ../../_static/style/bg_axNone_gmap.png
14961568
"""
1497-
if subset is None:
1569+
if subset is None and gmap is None:
14981570
subset = self.data.select_dtypes(include=np.number).columns
14991571

15001572
self.apply(
1501-
self._background_gradient,
1573+
_background_gradient,
15021574
cmap=cmap,
15031575
subset=subset,
15041576
axis=axis,
@@ -1507,75 +1579,10 @@ def background_gradient(
15071579
text_color_threshold=text_color_threshold,
15081580
vmin=vmin,
15091581
vmax=vmax,
1582+
gmap=gmap,
15101583
)
15111584
return self
15121585

1513-
@staticmethod
1514-
def _background_gradient(
1515-
s,
1516-
cmap="PuBu",
1517-
low: float = 0,
1518-
high: float = 0,
1519-
text_color_threshold: float = 0.408,
1520-
vmin: float | None = None,
1521-
vmax: float | None = None,
1522-
):
1523-
"""
1524-
Color background in a range according to the data.
1525-
"""
1526-
if (
1527-
not isinstance(text_color_threshold, (float, int))
1528-
or not 0 <= text_color_threshold <= 1
1529-
):
1530-
msg = "`text_color_threshold` must be a value from 0 to 1."
1531-
raise ValueError(msg)
1532-
1533-
with _mpl(Styler.background_gradient) as (plt, colors):
1534-
smin = np.nanmin(s.to_numpy()) if vmin is None else vmin
1535-
smax = np.nanmax(s.to_numpy()) if vmax is None else vmax
1536-
rng = smax - smin
1537-
# extend lower / upper bounds, compresses color range
1538-
norm = colors.Normalize(smin - (rng * low), smax + (rng * high))
1539-
# matplotlib colors.Normalize modifies inplace?
1540-
# https://github.com/matplotlib/matplotlib/issues/5427
1541-
rgbas = plt.cm.get_cmap(cmap)(norm(s.to_numpy(dtype=float)))
1542-
1543-
def relative_luminance(rgba) -> float:
1544-
"""
1545-
Calculate relative luminance of a color.
1546-
1547-
The calculation adheres to the W3C standards
1548-
(https://www.w3.org/WAI/GL/wiki/Relative_luminance)
1549-
1550-
Parameters
1551-
----------
1552-
color : rgb or rgba tuple
1553-
1554-
Returns
1555-
-------
1556-
float
1557-
The relative luminance as a value from 0 to 1
1558-
"""
1559-
r, g, b = (
1560-
x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4
1561-
for x in rgba[:3]
1562-
)
1563-
return 0.2126 * r + 0.7152 * g + 0.0722 * b
1564-
1565-
def css(rgba) -> str:
1566-
dark = relative_luminance(rgba) < text_color_threshold
1567-
text_color = "#f1f1f1" if dark else "#000000"
1568-
return f"background-color: {colors.rgb2hex(rgba)};color: {text_color};"
1569-
1570-
if s.ndim == 1:
1571-
return [css(rgba) for rgba in rgbas]
1572-
else:
1573-
return DataFrame(
1574-
[[css(rgba) for rgba in row] for row in rgbas],
1575-
index=s.index,
1576-
columns=s.columns,
1577-
)
1578-
15791586
def set_properties(self, subset=None, **kwargs) -> Styler:
15801587
"""
15811588
Set defined CSS-properties to each ``<td>`` HTML element within the given
@@ -2346,3 +2353,119 @@ def pred(part) -> bool:
23462353
else:
23472354
slice_ = [part if pred(part) else [part] for part in slice_]
23482355
return tuple(slice_)
2356+
2357+
2358+
def _validate_apply_axis_arg(
2359+
arg: FrameOrSeries | Sequence | np.ndarray,
2360+
arg_name: str,
2361+
dtype: Any | None,
2362+
data: FrameOrSeries,
2363+
) -> np.ndarray:
2364+
"""
2365+
For the apply-type methods, ``axis=None`` creates ``data`` as DataFrame, and for
2366+
``axis=[1,0]`` it creates a Series. Where ``arg`` is expected as an element
2367+
of some operator with ``data`` we must make sure that the two are compatible shapes,
2368+
or raise.
2369+
2370+
Parameters
2371+
----------
2372+
arg : sequence, Series or DataFrame
2373+
the user input arg
2374+
arg_name : string
2375+
name of the arg for use in error messages
2376+
dtype : numpy dtype, optional
2377+
forced numpy dtype if given
2378+
data : Series or DataFrame
2379+
underling subset of Styler data on which operations are performed
2380+
2381+
Returns
2382+
-------
2383+
ndarray
2384+
"""
2385+
dtype = {"dtype": dtype} if dtype else {}
2386+
# raise if input is wrong for axis:
2387+
if isinstance(arg, Series) and isinstance(data, DataFrame):
2388+
raise ValueError(
2389+
f"'{arg_name}' is a Series but underlying data for operations "
2390+
f"is a DataFrame since 'axis=None'"
2391+
)
2392+
elif isinstance(arg, DataFrame) and isinstance(data, Series):
2393+
raise ValueError(
2394+
f"'{arg_name}' is a DataFrame but underlying data for "
2395+
f"operations is a Series with 'axis in [0,1]'"
2396+
)
2397+
elif isinstance(arg, (Series, DataFrame)): # align indx / cols to data
2398+
arg = arg.reindex_like(data, method=None).to_numpy(**dtype)
2399+
else:
2400+
arg = np.asarray(arg, **dtype)
2401+
assert isinstance(arg, np.ndarray) # mypy requirement
2402+
if arg.shape != data.shape: # check valid input
2403+
raise ValueError(
2404+
f"supplied '{arg_name}' is not correct shape for data over "
2405+
f"selected 'axis': got {arg.shape}, "
2406+
f"expected {data.shape}"
2407+
)
2408+
return arg
2409+
2410+
2411+
def _background_gradient(
2412+
data,
2413+
cmap="PuBu",
2414+
low: float = 0,
2415+
high: float = 0,
2416+
text_color_threshold: float = 0.408,
2417+
vmin: float | None = None,
2418+
vmax: float | None = None,
2419+
gmap: Sequence | np.ndarray | FrameOrSeries | None = None,
2420+
):
2421+
"""
2422+
Color background in a range according to the data or a gradient map
2423+
"""
2424+
if gmap is None: # the data is used the gmap
2425+
gmap = data.to_numpy(dtype=float)
2426+
else: # else validate gmap against the underlying data
2427+
gmap = _validate_apply_axis_arg(gmap, "gmap", float, data)
2428+
2429+
with _mpl(Styler.background_gradient) as (plt, colors):
2430+
smin = np.nanmin(gmap) if vmin is None else vmin
2431+
smax = np.nanmax(gmap) if vmax is None else vmax
2432+
rng = smax - smin
2433+
# extend lower / upper bounds, compresses color range
2434+
norm = colors.Normalize(smin - (rng * low), smax + (rng * high))
2435+
rgbas = plt.cm.get_cmap(cmap)(norm(gmap))
2436+
2437+
def relative_luminance(rgba) -> float:
2438+
"""
2439+
Calculate relative luminance of a color.
2440+
2441+
The calculation adheres to the W3C standards
2442+
(https://www.w3.org/WAI/GL/wiki/Relative_luminance)
2443+
2444+
Parameters
2445+
----------
2446+
color : rgb or rgba tuple
2447+
2448+
Returns
2449+
-------
2450+
float
2451+
The relative luminance as a value from 0 to 1
2452+
"""
2453+
r, g, b = (
2454+
x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4
2455+
for x in rgba[:3]
2456+
)
2457+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
2458+
2459+
def css(rgba) -> str:
2460+
dark = relative_luminance(rgba) < text_color_threshold
2461+
text_color = "#f1f1f1" if dark else "#000000"
2462+
return f"background-color: {colors.rgb2hex(rgba)};color: {text_color};"
2463+
2464+
if data.ndim == 1:
2465+
return [css(rgba) for rgba in rgbas]
2466+
else:
2467+
return DataFrame(
2468+
[[css(rgba) for rgba in row] for row in rgbas],
2469+
index=data.index,
2470+
columns=data.columns,
2471+
)

0 commit comments

Comments
 (0)