diff --git a/doc/source/style.ipynb b/doc/source/style.ipynb index 2cacbb19d81bb..427b18b988aef 100644 --- a/doc/source/style.ipynb +++ b/doc/source/style.ipynb @@ -2,7 +2,9 @@ "cells": [ { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "collapsed": true + }, "source": [ "# Styling\n", "\n", @@ -87,9 +89,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style" @@ -107,9 +107,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.highlight_null().render().split('\\n')[:10]" @@ -160,9 +158,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "s = df.style.applymap(color_negative_red)\n", @@ -208,9 +204,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.apply(highlight_max)" @@ -234,9 +228,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.\\\n", @@ -290,9 +282,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.apply(highlight_max, color='darkorange', axis=None)" @@ -340,9 +330,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.apply(highlight_max, subset=['B', 'C', 'D'])" @@ -358,9 +346,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.applymap(color_negative_red,\n", @@ -393,9 +379,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.format(\"{:.2%}\")" @@ -411,9 +395,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.format({'B': \"{:0<4.0f}\", 'D': '{:+.2f}'})" @@ -429,9 +411,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.format({\"B\": lambda x: \"±{:.2f}\".format(abs(x))})" @@ -454,9 +434,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.highlight_null(null_color='red')" @@ -472,9 +450,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "import seaborn as sns\n", @@ -495,9 +471,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "# Uses the full color range\n", @@ -507,9 +481,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "# Compress the color range\n", @@ -523,67 +495,128 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "You can include \"bar charts\" in your DataFrame." + "There's also `.highlight_min` and `.highlight_max`." ] }, { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ - "df.style.bar(subset=['A', 'B'], color='#d65f5f')" + "df.style.highlight_max(axis=0)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "There's also `.highlight_min` and `.highlight_max`." + "Use `Styler.set_properties` when the style doesn't actually depend on the values." ] }, { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ - "df.style.highlight_max(axis=0)" + "df.style.set_properties(**{'background-color': 'black',\n", + " 'color': 'lawngreen',\n", + " 'border-color': 'white'})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Bar charts" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can include \"bar charts\" in your DataFrame." ] }, { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ - "df.style.highlight_min(axis=0)" + "df.style.bar(subset=['A', 'B'], color='#d65f5f')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Use `Styler.set_properties` when the style doesn't actually depend on the values." + "New in version 0.20.0 is the ability to customize further the bar chart: You can now have the `df.style.bar` be centered on zero or midpoint value (in addition to the already existing way of having the min value at the left side of the cell), and you can pass a list of `[color_negative, color_positive]`.\n", + "\n", + "Here's how you can change the above with the new `align='mid'` option:" ] }, { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ - "df.style.set_properties(**{'background-color': 'black',\n", - " 'color': 'lawngreen',\n", - " 'border-color': 'white'})" + "df.style.bar(subset=['A', 'B'], align='mid', color=['#d65f5f', '#5fba7d'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following example aims to give a highlight of the behavior of the new align options:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from IPython.display import HTML\n", + "\n", + "# Test series\n", + "test1 = pd.Series([-100,-60,-30,-20], name='All Negative')\n", + "test2 = pd.Series([10,20,50,100], name='All Positive')\n", + "test3 = pd.Series([-10,-5,0,90], name='Both Pos and Neg')\n", + "\n", + "head = \"\"\"\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n", + "\"\"\"\n", + "\n", + "aligns = ['left','zero','mid']\n", + "for align in aligns:\n", + " row = \"\".format(align)\n", + " for serie in [test1,test2,test3]:\n", + " s = serie.copy()\n", + " s.name=''\n", + " row += \"\".format(s.to_frame().style.bar(align=align, \n", + " color=['#d65f5f', '#5fba7d'], \n", + " width=100).render()) #testn['width']\n", + " row += ''\n", + " head += row\n", + " \n", + "head+= \"\"\"\n", + "\n", + "
AlignAll NegativeAll PositiveBoth Neg and Pos
{}{}
\"\"\"\n", + " \n", + "\n", + "HTML(head)" ] }, { @@ -603,9 +636,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df2 = -df\n", @@ -616,9 +647,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "style2 = df2.style\n", @@ -671,9 +700,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "with pd.option_context('display.precision', 2):\n", @@ -693,9 +720,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style\\\n", @@ -728,9 +753,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.set_caption('Colormaps, with a caption.')\\\n", @@ -756,9 +779,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "from IPython.display import HTML\n", @@ -854,9 +875,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "from IPython.html import widgets\n", @@ -892,9 +911,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "np.random.seed(25)\n", @@ -993,9 +1010,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "%mkdir templates" @@ -1012,9 +1027,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "%%file templates/myhtml.tpl\n", @@ -1065,9 +1078,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "MyStyler(df)" @@ -1083,9 +1094,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "HTML(MyStyler(df).render(table_title=\"Extending Example\"))" @@ -1101,9 +1110,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "EasyStyler = Styler.from_custom_template(\"templates\", \"myhtml.tpl\")\n", @@ -1120,9 +1127,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "with open(\"template_structure.html\") as f:\n", diff --git a/doc/source/whatsnew/v0.20.0.txt b/doc/source/whatsnew/v0.20.0.txt index 6e4756c3c5245..4a95e580af9fd 100644 --- a/doc/source/whatsnew/v0.20.0.txt +++ b/doc/source/whatsnew/v0.20.0.txt @@ -524,6 +524,8 @@ Other Enhancements - ``parallel_coordinates()`` has gained a ``sort_labels`` keyword arg that sorts class labels and the colours assigned to them (:issue:`15908`) - Options added to allow one to turn on/off using ``bottleneck`` and ``numexpr``, see :ref:`here ` (:issue:`16157`) +- ``DataFrame.style.bar()`` now accepts two more options to further customize the bar chart. Bar alignment is set with ``align='left'|'mid'|'zero'``, the default is "left", which is backward compatible; You can now pass a list of ``color=[color_negative, color_positive]``. (:issue:`14757`) + .. _ISO 8601 duration: https://en.wikipedia.org/wiki/ISO_8601#Durations diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 83062e7d764cd..f1ff2966dca48 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -23,6 +23,7 @@ import numpy as np import pandas as pd +from pandas.api.types import is_list_like from pandas.compat import range from pandas.core.config import get_option from pandas.core.generic import _shared_docs @@ -868,30 +869,141 @@ def set_properties(self, subset=None, **kwargs): return self.applymap(f, subset=subset) @staticmethod - def _bar(s, color, width): + def _bar_left(s, color, width, base): + """ + The minimum value is aligned at the left of the cell + Parameters + ---------- + color: 2-tuple/list, of [``color_negative``, ``color_positive``] + width: float + A number between 0 or 100. The largest value will cover ``width`` + percent of the cell's width + base: str + The base css format of the cell, e.g.: + ``base = 'width: 10em; height: 80%;'`` + Returns + ------- + self : Styler + """ normed = width * (s - s.min()) / (s.max() - s.min()) - - base = 'width: 10em; height: 80%;' - attrs = (base + 'background: linear-gradient(90deg,{c} {w}%, ' + zero_normed = width * (0 - s.min()) / (s.max() - s.min()) + attrs = (base + 'background: linear-gradient(90deg,{c} {w:.1f}%, ' 'transparent 0%)') - return [attrs.format(c=color, w=x) if x != 0 else base for x in normed] - def bar(self, subset=None, axis=0, color='#d65f5f', width=100): + return [base if x == 0 else attrs.format(c=color[0], w=x) + if x < zero_normed + else attrs.format(c=color[1], w=x) if x >= zero_normed + else base for x in normed] + + @staticmethod + def _bar_center_zero(s, color, width, base): + """ + Creates a bar chart where the zero is centered in the cell + Parameters + ---------- + color: 2-tuple/list, of [``color_negative``, ``color_positive``] + width: float + A number between 0 or 100. The largest value will cover ``width`` + percent of the cell's width + base: str + The base css format of the cell, e.g.: + ``base = 'width: 10em; height: 80%;'`` + Returns + ------- + self : Styler + """ + + # Either the min or the max should reach the edge + # (50%, centered on zero) + m = max(abs(s.min()), abs(s.max())) + + normed = s * 50 * width / (100.0 * m) + + attrs_neg = (base + 'background: linear-gradient(90deg, transparent 0%' + ', transparent {w:.1f}%, {c} {w:.1f}%, ' + '{c} 50%, transparent 50%)') + + attrs_pos = (base + 'background: linear-gradient(90deg, transparent 0%' + ', transparent 50%, {c} 50%, {c} {w:.1f}%, ' + 'transparent {w:.1f}%)') + + return [attrs_pos.format(c=color[1], w=(50 + x)) if x >= 0 + else attrs_neg.format(c=color[0], w=(50 + x)) + for x in normed] + + @staticmethod + def _bar_center_mid(s, color, width, base): + """ + Creates a bar chart where the midpoint is centered in the cell + Parameters + ---------- + color: 2-tuple/list, of [``color_negative``, ``color_positive``] + width: float + A number between 0 or 100. The largest value will cover ``width`` + percent of the cell's width + base: str + The base css format of the cell, e.g.: + ``base = 'width: 10em; height: 80%;'`` + Returns + ------- + self : Styler + """ + + if s.min() >= 0: + # In this case, we place the zero at the left, and the max() should + # be at width + zero = 0.0 + slope = width / s.max() + elif s.max() <= 0: + # In this case, we place the zero at the right, and the min() + # should be at 100-width + zero = 100.0 + slope = width / -s.min() + else: + slope = width / (s.max() - s.min()) + zero = (100.0 + width) / 2.0 - slope * s.max() + + normed = zero + slope * s + + attrs_neg = (base + 'background: linear-gradient(90deg, transparent 0%' + ', transparent {w:.1f}%, {c} {w:.1f}%, ' + '{c} {zero:.1f}%, transparent {zero:.1f}%)') + + attrs_pos = (base + 'background: linear-gradient(90deg, transparent 0%' + ', transparent {zero:.1f}%, {c} {zero:.1f}%, ' + '{c} {w:.1f}%, transparent {w:.1f}%)') + + return [attrs_pos.format(c=color[1], zero=zero, w=x) if x > zero + else attrs_neg.format(c=color[0], zero=zero, w=x) + for x in normed] + + def bar(self, subset=None, axis=0, color='#d65f5f', width=100, + align='left'): """ Color the background ``color`` proptional to the values in each column. Excludes non-numeric data by default. - .. versionadded:: 0.17.1 - Parameters ---------- subset: IndexSlice, default None a valid slice for ``data`` to limit the style application to axis: int - color: str + color: str or 2-tuple/list + If a str is passed, the color is the same for both + negative and positive numbers. If 2-tuple/list is used, the + first element is the color_negative and the second is the + color_positive (eg: ['#d65f5f', '#5fba7d']) width: float A number between 0 or 100. The largest value will cover ``width`` percent of the cell's width + align : {'left', 'zero',' mid'}, default 'left' + - 'left' : the min value starts at the left of the cell + - 'zero' : a value of zero is located at the center of the cell + - 'mid' : the center of the cell is at (max-min)/2, or + if values are all negative (positive) the zero is aligned + at the right (left) of the cell + + .. versionadded:: 0.20.0 Returns ------- @@ -899,8 +1011,32 @@ def bar(self, subset=None, axis=0, color='#d65f5f', width=100): """ subset = _maybe_numeric_slice(self.data, subset) subset = _non_reducing_slice(subset) - self.apply(self._bar, subset=subset, axis=axis, color=color, - width=width) + + base = 'width: 10em; height: 80%;' + + if not(is_list_like(color)): + color = [color, color] + elif len(color) == 1: + color = [color[0], color[0]] + elif len(color) > 2: + msg = ("Must pass `color` as string or a list-like" + " of length 2: [`color_negative`, `color_positive`]\n" + "(eg: color=['#d65f5f', '#5fba7d'])") + raise ValueError(msg) + + if align == 'left': + self.apply(self._bar_left, subset=subset, axis=axis, color=color, + width=width, base=base) + elif align == 'zero': + self.apply(self._bar_center_zero, subset=subset, axis=axis, + color=color, width=width, base=base) + elif align == 'mid': + self.apply(self._bar_center_mid, subset=subset, axis=axis, + color=color, width=width, base=base) + else: + msg = ("`align` must be one of {'left', 'zero',' mid'}") + raise ValueError(msg) + return self def highlight_max(self, subset=None, color='yellow', axis=0): diff --git a/pandas/tests/io/formats/test_style.py b/pandas/tests/io/formats/test_style.py index f421c0f8e6d69..9219ac1c9c26b 100644 --- a/pandas/tests/io/formats/test_style.py +++ b/pandas/tests/io/formats/test_style.py @@ -266,7 +266,7 @@ def test_empty(self): {'props': [['', '']], 'selector': 'row1_col0'}] assert result == expected - def test_bar(self): + def test_bar_align_left(self): df = pd.DataFrame({'A': [0, 1, 2]}) result = df.style.bar()._compute().ctx expected = { @@ -299,7 +299,7 @@ def test_bar(self): result = df.style.bar(color='red', width=50)._compute().ctx assert result == expected - def test_bar_0points(self): + def test_bar_align_left_0points(self): df = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) result = df.style.bar()._compute().ctx expected = {(0, 0): ['width: 10em', ' height: 80%'], @@ -349,6 +349,118 @@ def test_bar_0points(self): ', transparent 0%)']} assert result == expected + def test_bar_align_mid_pos_and_neg(self): + df = pd.DataFrame({'A': [-10, 0, 20, 90]}) + + result = df.style.bar(align='mid', color=[ + '#d65f5f', '#5fba7d'])._compute().ctx + + expected = {(0, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 0.0%, #d65f5f 0.0%, ' + '#d65f5f 10.0%, transparent 10.0%)'], + (1, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 10.0%, ' + '#d65f5f 10.0%, #d65f5f 10.0%, ' + 'transparent 10.0%)'], + (2, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 10.0%, #5fba7d 10.0%' + ', #5fba7d 30.0%, transparent 30.0%)'], + (3, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 10.0%, ' + '#5fba7d 10.0%, #5fba7d 100.0%, ' + 'transparent 100.0%)']} + + self.assertEqual(result, expected) + + def test_bar_align_mid_all_pos(self): + df = pd.DataFrame({'A': [10, 20, 50, 100]}) + + result = df.style.bar(align='mid', color=[ + '#d65f5f', '#5fba7d'])._compute().ctx + + expected = {(0, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 0.0%, #5fba7d 0.0%, ' + '#5fba7d 10.0%, transparent 10.0%)'], + (1, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 0.0%, #5fba7d 0.0%, ' + '#5fba7d 20.0%, transparent 20.0%)'], + (2, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 0.0%, #5fba7d 0.0%, ' + '#5fba7d 50.0%, transparent 50.0%)'], + (3, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 0.0%, #5fba7d 0.0%, ' + '#5fba7d 100.0%, transparent 100.0%)']} + + self.assertEqual(result, expected) + + def test_bar_align_mid_all_neg(self): + df = pd.DataFrame({'A': [-100, -60, -30, -20]}) + + result = df.style.bar(align='mid', color=[ + '#d65f5f', '#5fba7d'])._compute().ctx + + expected = {(0, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 0.0%, ' + '#d65f5f 0.0%, #d65f5f 100.0%, ' + 'transparent 100.0%)'], + (1, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 40.0%, ' + '#d65f5f 40.0%, #d65f5f 100.0%, ' + 'transparent 100.0%)'], + (2, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 70.0%, ' + '#d65f5f 70.0%, #d65f5f 100.0%, ' + 'transparent 100.0%)'], + (3, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 80.0%, ' + '#d65f5f 80.0%, #d65f5f 100.0%, ' + 'transparent 100.0%)']} + assert result == expected + + def test_bar_align_zero_pos_and_neg(self): + # See https://github.com/pandas-dev/pandas/pull/14757 + df = pd.DataFrame({'A': [-10, 0, 20, 90]}) + + result = df.style.bar(align='zero', color=[ + '#d65f5f', '#5fba7d'], width=90)._compute().ctx + + expected = {(0, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 45.0%, ' + '#d65f5f 45.0%, #d65f5f 50%, ' + 'transparent 50%)'], + (1, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 50%, ' + '#5fba7d 50%, #5fba7d 50.0%, ' + 'transparent 50.0%)'], + (2, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 50%, #5fba7d 50%, ' + '#5fba7d 60.0%, transparent 60.0%)'], + (3, 0): ['width: 10em', ' height: 80%', + 'background: linear-gradient(90deg, ' + 'transparent 0%, transparent 50%, #5fba7d 50%, ' + '#5fba7d 95.0%, transparent 95.0%)']} + assert result == expected + + def test_bar_bad_align_raises(self): + df = pd.DataFrame({'A': [-100, -60, -30, -20]}) + with pytest.raises(ValueError): + df.style.bar(align='poorly', color=['#d65f5f', '#5fba7d']) + def test_highlight_null(self, null_color='red'): df = pd.DataFrame({'A': [0, np.nan]}) result = df.style.highlight_null()._compute().ctx