Skip to content

ENH: add a gradient map to background gradient #39930

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 45 commits into from
Apr 8, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
ce4cb00
add a gradient map to background gradient
attack68 Feb 20, 2021
d12d0af
whats new
attack68 Feb 20, 2021
e0cf6f0
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Feb 22, 2021
ea7b5ee
add examples
attack68 Feb 22, 2021
d0a3b9e
add examples
attack68 Feb 22, 2021
ddaa18a
add examples
attack68 Feb 22, 2021
72c0825
shape validation
attack68 Feb 22, 2021
c6adc82
update docs
attack68 Feb 22, 2021
898bd8c
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Feb 23, 2021
aa3a83e
add tests to new modules.
attack68 Feb 23, 2021
3bd0c51
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Feb 23, 2021
56e80ef
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Feb 25, 2021
af852d0
update reshaping
attack68 Feb 26, 2021
3c06bd3
update reshaping
attack68 Feb 26, 2021
a696353
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Feb 27, 2021
233e4c3
mypy fix
attack68 Feb 27, 2021
357c1bb
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Feb 28, 2021
59d5a57
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 1, 2021
ffa19c7
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 2, 2021
1f44a5b
req changes
attack68 Mar 4, 2021
233135a
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 5, 2021
0245960
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 5, 2021
64136e9
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 7, 2021
73a5cb1
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 8, 2021
a92e42d
html tests
attack68 Mar 9, 2021
0ec339e
align DataFrame and Series gmap with underlying data
attack68 Mar 9, 2021
b6c2fda
align DataFrame and Series gmap with underlying data
attack68 Mar 9, 2021
bd025b8
mypy fixup
attack68 Mar 9, 2021
277e2d7
make elif
attack68 Mar 10, 2021
7141e4c
add tests
attack68 Mar 10, 2021
439a853
add arg shape validation function
attack68 Mar 17, 2021
88117eb
change 'right' text to 'correct' to avoid ambiguity
attack68 Mar 17, 2021
380d2d3
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 17, 2021
b7b7179
mypy updates
attack68 Mar 17, 2021
2656c75
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 24, 2021
41f0e8c
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 27, 2021
02d13f4
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 28, 2021
8db2b50
partial out axis_
attack68 Mar 28, 2021
3612431
remove axis and remove staticmethod
attack68 Mar 29, 2021
e6c3aaa
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Mar 31, 2021
9d247e9
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Apr 2, 2021
aad0fa9
Merge remote-tracking branch 'upstream/master' into background_gradie…
attack68 Apr 2, 2021
968e99f
pre-commit fix
attack68 Apr 2, 2021
130d735
merge upstream master
attack68 Apr 6, 2021
946d11f
fix typing to new standard
attack68 Apr 6, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added doc/source/_static/style/bg_ax0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/source/_static/style/bg_axNone.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/source/_static/style/bg_axNone_gmap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/source/_static/style/bg_axNone_lowhigh.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/source/_static/style/bg_axNone_vminvmax.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/source/_static/style/bg_gmap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions doc/source/whatsnew/v1.3.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Other enhancements
- :meth:`.Styler.set_tooltips_class` and :meth:`.Styler.set_table_styles` amended to optionally allow certain css-string input arguments (:issue:`39564`)
- :meth:`.Styler.apply` now more consistently accepts ndarray function returns, i.e. in all cases for ``axis`` is ``0, 1 or None`` (:issue:`39359`)
- :meth:`.Styler.apply` and :meth:`.Styler.applymap` now raise errors if wrong format CSS is passed on render (:issue:`39660`)
- :meth:`.Styler.background_gradient` now allows the ability to supply a specific gradient map (:issue:`22727`)
- :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`)
- :meth:`pandas.read_stata` and :class:`StataReader` support reading data from compressed files.
- Add support for parsing ``ISO 8601``-like timestamps with negative signs to :meth:`pandas.Timedelta` (:issue:`37172`)
Expand Down
133 changes: 102 additions & 31 deletions pandas/io/formats/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -1308,63 +1308,130 @@ def background_gradient(
text_color_threshold: float = 0.408,
vmin: Optional[float] = None,
vmax: Optional[float] = None,
gmap: Optional[Sequence] = None,
) -> Styler:
"""
Color the background in a gradient style.

The background color is determined according
to the data in each column (optionally row). Requires matplotlib.
to the data in each column, row or frame, or by a given
gradient map. Requires matplotlib.

Parameters
----------
cmap : str or colormap
Matplotlib colormap.
low : float
Compress the range by the low.
Compress the color range at the low end. This is a multiple of the data
range to extend below the minimum; good values usually in [0, 1],
defaults to 0.
high : float
Compress the range by the high.
Compress the color range at the high end. This is a multiple of the data
range to extend above the maximum; good values usually in [0, 1],
defaults to 0.
axis : {0 or 'index', 1 or 'columns', None}, default 0
Apply to each column (``axis=0`` or ``'index'``), to each row
(``axis=1`` or ``'columns'``), or to the entire DataFrame at once
with ``axis=None``.
subset : IndexSlice
A valid slice for ``data`` to limit the style application to.
text_color_threshold : float or int
Luminance threshold for determining text color. Facilitates text
visibility across varying background colors. From 0 to 1.
0 = all text is dark colored, 1 = all text is light colored.
Luminance threshold for determining text color in [0, 1]. Facilitates text
visibility across varying background colors. All text is dark if 0, and
light if 1, defaults to 0.408.

.. versionadded:: 0.24.0

vmin : float, optional
Minimum data value that corresponds to colormap minimum value.
When None (default): the minimum value of the data will be used.
If not specified the minimum value of the data (or gmap) will be used.

.. versionadded:: 1.0.0

vmax : float, optional
Maximum data value that corresponds to colormap maximum value.
When None (default): the maximum value of the data will be used.
If not specified the maximum value of the data (or gmap) will be used.

.. versionadded:: 1.0.0

gmap : array-like, optional
Gradient map for determining the background colors. If not supplied
will use the input data from rows, columns or frame. Must be an
identical shape for sampling columns, rows or DataFrame based on ``axis``.
If supplied ``vmin`` and ``vmax`` should be given relative to this
gradient map.

.. versionadded:: 1.3.0

Returns
-------
self : Styler

Raises
------
ValueError
If ``text_color_threshold`` is not a value from 0 to 1.

Notes
-----
Set ``text_color_threshold`` or tune ``low`` and ``high`` to keep the
text legible by not using the entire range of the color map. The range
of the data is extended by ``low * (x.max() - x.min())`` and ``high *
(x.max() - x.min())`` before normalizing.
When using ``low`` and ``high`` the range
of the gradient, given by the data if ``gmap`` is not given or by ``gmap``,
is extended at the low end effectively by
`map.min - low * map.range` and at the high end by
`map.max + high * map.range` before the colors are normalized and determined.

If combining with ``vmin`` and ``vmax`` the `map.min`, `map.max` and
`map.range` are replaced by values according to the values derived from
``vmin`` and ``vmax``.

This method will preselect numeric columns and ignore non-numeric columns
unless a ``gmap`` is supplied in which case no preselection occurs.

Examples
--------
>>> df = pd.DataFrame({
... 'City': ['Stockholm', 'Oslo', 'Copenhagen'],
... 'Temp (c)': [21.6, 22.4, 24.5],
... 'Rain (mm)': [5.0, 13.3, 0.0],
... 'Wind (m/s)': [3.2, 3.1, 6.7]
... })

Shading the values column-wise, with ``axis=0``

>>> df.style.background_gradient(axis=0)

.. figure:: ../../_static/style/bg_ax0.png

Shading all values collectively using ``axis=None``

>>> df.style.background_gradient(axis=None)

.. figure:: ../../_static/style/bg_axNone.png

Compress the color map from the both ``low`` and ``high`` ends

>>> df.style.background_gradient(axis=None, low=0.75, high=1.0)

.. figure:: ../../_static/style/bg_axNone_lowhigh.png

Manually setting ``vmin`` and ``vmax`` gradient thresholds

>>> df.style.background_gradient(axis=None, vmin=6.7, vmax=21.6)

.. figure:: ../../_static/style/bg_axNone_vminvmax.png

Setting a ``gmap`` and applying to all columns with another ``cmap``

>>> df.style.background_gradient(axis=0, gmap=df['Temp (c)'], cmap='YlOrRd')

.. figure:: ../../_static/style/bg_gmap.png

Setting the gradient map for a dataframe (i.e. ``axis=None``), we need to
explicitly state the numeric columns here to match the ``gmap`` shape

>>> gmap = np.array([[1,2,3], [2,3,4], [3,4,5]])
>>> df.style.background_gradient(axis=None, gmap=gmap,
... cmap='YlOrRd', subset=['Temp (c)', 'Rain (mm)', 'Wind (m/s)']
... )

.. figure:: ../../_static/style/bg_axNone_gmap.png
"""
if subset is None:
if subset is None and gmap is None:
subset = self.data.select_dtypes(include=np.number).columns

self.apply(
Expand All @@ -1377,6 +1444,7 @@ def background_gradient(
text_color_threshold=text_color_threshold,
vmin=vmin,
vmax=vmax,
gmap=gmap,
)
return self

Expand All @@ -1389,26 +1457,29 @@ def _background_gradient(
text_color_threshold: float = 0.408,
vmin: Optional[float] = None,
vmax: Optional[float] = None,
gmap: Optional[Sequence] = None,
):
"""
Color background in a range according to the data.
Color background in a range according to the data or a gradient map
"""
if (
not isinstance(text_color_threshold, (float, int))
or not 0 <= text_color_threshold <= 1
):
msg = "`text_color_threshold` must be a value from 0 to 1."
raise ValueError(msg)

if gmap is None:
gmap = s.to_numpy(dtype=float)
else:
try:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, shouldn't this just be a DataFrame that is aligned? why go to all of this complication (and its fragile)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all cases the gmap has to match the data shape s if it doesn't it would just raise downstream error. This way, imo the code is simple and concise and provides both flexibility of input and helpful user error feedback.

in the case axis in [0,1] then s, the data, is guaranteed a 1d-series, and you can supply, as gmap either 1d or 2d inputs provided the second dim has length 1 so it can be reshaped:

df = DataFrame([[1,2],[3,4]])
...background_gradient(gmap=[2,4], axis=0)  # 1d list
...background_gradient(gmap=df[1], axis=0)  # series
...background_gradient(gmap=df[[1]], axis=0)  # dataframe
...background_gradient(gmap=[[2],[4]], axis=0)  # 2d list
...background_gradient(gmap=np.array([[2],[4]]), axis=0) # 2d numpy
...background_gradient(gmap=np.array([2,4]), axis=0)  # 1d numpy             ALL WORK

in the case axis is None and s is a DataFrame with 2 cols or more then you need a 2d input (or in edge-case a ravelled 1d input):

....background_gradient(gmap=[[2,2],[4,4]], axis=None)  # 2d list
...background_gradient(gmap=np.array([[2,2],[4,4]]), axis=None)  # 2d numpy
...background_gradient(gmap=df, axis=None)  # dataframe
...background_gradient(gmap=[2,2,4,4], axis=None)  # 1d edge case.

In the case axis is None and the the data is a 1-col dataframe any of the 1d cases will again work, e.g

.background_gradient(gmap=[2,4], subset=[0], axis=None)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to for sure align if a dataframe is passed.
ok on 2D. why is accepting 1D ok ? this is just too flexible. I dont' see any advantage to this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok how's this: I've split the 1d and 2d cases still compare shape at end with error if they don't match.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think you are missing my point. what does a 2D gradient map mean / do? I don't see any tests that actually use this nor what its supposed to do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are tests now for 2D arrays and DataFrames, for minimalist correct functionality, but not to demonstrate purpose.

the purpose of a 2D map is generally improved visualisation of 2D tabular data. If you just shade the background of a dataframe according to data that you can already read in the cell it just looks nice but doesn't convey information. But if you can, optionally, shade a background based on an alternate 2D map (axis=None) then you are providing a visual cross section of information analysis, like my examples given below, and say the performance grid here You can't achieve this with only the 1D (axis=0,1) functionality.

You could technically do heat maps currently but you would have to hide the cell data with CSS. This PR offers an easier solution for something like Look at 9. Heat Map (no text in cell). And you can only shade string cells with a provided quantitative map: something like doing this

gmap = np.asarray(gmap, dtype=float).reshape(s.shape)
except ValueError:
raise ValueError(
"supplied 'gmap' is not right shape for data over "
f"selected 'axis': got {np.asarray(gmap).shape}, "
f"expected {s.shape}"
)
with _mpl(Styler.background_gradient) as (plt, colors):
smin = np.nanmin(s.to_numpy()) if vmin is None else vmin
smax = np.nanmax(s.to_numpy()) if vmax is None else vmax
smin = np.nanmin(gmap) if vmin is None else vmin
smax = np.nanmax(gmap) if vmax is None else vmax
rng = smax - smin
# extend lower / upper bounds, compresses color range
norm = colors.Normalize(smin - (rng * low), smax + (rng * high))
# matplotlib colors.Normalize modifies inplace?
# https://github.com/matplotlib/matplotlib/issues/5427
rgbas = plt.cm.get_cmap(cmap)(norm(s.to_numpy(dtype=float)))
rgbas = plt.cm.get_cmap(cmap)(norm(gmap))

def relative_luminance(rgba) -> float:
"""
Expand Down
58 changes: 49 additions & 9 deletions pandas/tests/io/formats/style/test_matplotlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,6 @@ def test_text_color_threshold(self, cmap, expected):
for k in expected.keys():
assert result[k] == expected[k]

@pytest.mark.parametrize("text_color_threshold", [1.1, "1", -1, [2, 2]])
def test_text_color_threshold_raises(self, text_color_threshold):
df = DataFrame([[1, 2], [2, 4]], columns=["A", "B"])
msg = "`text_color_threshold` must be a value from 0 to 1."
with pytest.raises(ValueError, match=msg):
df.style.background_gradient(
text_color_threshold=text_color_threshold
)._compute()

def test_background_gradient_axis(self):
df = DataFrame([[1, 2], [2, 4]], columns=["A", "B"])

Expand Down Expand Up @@ -106,3 +97,52 @@ def test_background_gradient_int64(self):
assert ctx2[(0, 0)] == ctx1[(0, 0)]
assert ctx2[(1, 0)] == ctx1[(1, 0)]
assert ctx2[(2, 0)] == ctx1[(2, 0)]

@pytest.mark.parametrize(
"axis, gmap, expected",
[
(
0,
[1, 2],
{
(0, 0): [("background-color", "#fff7fb"), ("color", "#000000")],
(1, 0): [("background-color", "#023858"), ("color", "#f1f1f1")],
(0, 1): [("background-color", "#fff7fb"), ("color", "#000000")],
(1, 1): [("background-color", "#023858"), ("color", "#f1f1f1")],
},
),
(
1,
[1, 2],
{
(0, 0): [("background-color", "#fff7fb"), ("color", "#000000")],
(1, 0): [("background-color", "#fff7fb"), ("color", "#000000")],
(0, 1): [("background-color", "#023858"), ("color", "#f1f1f1")],
(1, 1): [("background-color", "#023858"), ("color", "#f1f1f1")],
},
),
(
None,
np.array([[2, 1], [1, 2]]),
{
(0, 0): [("background-color", "#023858"), ("color", "#f1f1f1")],
(1, 0): [("background-color", "#fff7fb"), ("color", "#000000")],
(0, 1): [("background-color", "#fff7fb"), ("color", "#000000")],
(1, 1): [("background-color", "#023858"), ("color", "#f1f1f1")],
},
),
],
)
def test_background_gradient_gmap(self, axis, gmap, expected):
df = DataFrame([[1, 2], [2, 1]])
result = df.style.background_gradient(axis=axis, gmap=gmap)._compute().ctx
assert result == expected

@pytest.mark.parametrize(
"gmap, axis", [([1, 2, 3], 0), ([1, 2], 1), (np.array([[1, 2], [1, 2]]), None)]
)
def test_background_gradient_gmap_raises(self, gmap, axis):
df = DataFrame([[1, 2, 3], [1, 2, 3]])
msg = "supplied 'gmap' is not right shape"
with pytest.raises(ValueError, match=msg):
df.style.background_gradient(gmap=gmap, axis=axis)._compute()