diff --git a/doc/source/html-styling.ipynb b/doc/source/html-styling.ipynb index 1a97378fd30b1..841dc34f5cb04 100644 --- a/doc/source/html-styling.ipynb +++ b/doc/source/html-styling.ipynb @@ -2,7 +2,9 @@ "cells": [ { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "collapsed": true + }, "source": [ "*New in version 0.17.1*\n", "\n", @@ -518,7 +520,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "You can include \"bar charts\" in your DataFrame." + "There's also `.highlight_min` and `.highlight_max`." ] }, { @@ -529,14 +531,25 @@ }, "outputs": [], "source": [ - "df.style.bar(subset=['A', 'B'], color='#d65f5f')" + "df.style.highlight_max(axis=0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "df.style.highlight_min(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." ] }, { @@ -547,7 +560,23 @@ }, "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." ] }, { @@ -558,14 +587,16 @@ }, "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:" ] }, { @@ -576,9 +607,62 @@ }, "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": { + "collapsed": false + }, + "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)" ] }, { @@ -961,7 +1045,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.5.1" + "version": "3.5.2" } }, "nbformat": 4, diff --git a/doc/source/whatsnew/v0.20.0.txt b/doc/source/whatsnew/v0.20.0.txt index 2e1cc396287ce..f3954688ce126 100644 --- a/doc/source/whatsnew/v0.20.0.txt +++ b/doc/source/whatsnew/v0.20.0.txt @@ -366,6 +366,8 @@ Other Enhancements - ``pandas.io.json.json_normalize()`` with an empty ``list`` will return an empty ``DataFrame`` (:issue:`15534`) - ``pandas.io.json.json_normalize()`` has gained a ``sep`` option that accepts ``str`` to separate joined fields; the default is ".", which is backward compatible. (:issue:`14883`) +- ``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/formats/style.py b/pandas/formats/style.py index e712010a8b4f2..f8c61236a6eff 100644 --- a/pandas/formats/style.py +++ b/pandas/formats/style.py @@ -17,7 +17,7 @@ "or `pip install Jinja2`" raise ImportError(msg) -from pandas.types.common import is_float, is_string_like +from pandas.types.common import is_float, is_string_like, is_list_like import numpy as np import pandas as pd @@ -857,19 +857,125 @@ def set_properties(self, subset=None, **kwargs): return self.applymap(f, subset=subset) @staticmethod - def _bar(s, color, width): - normed = width * (s - s.min()) / (s.max() - s.min()) + def _bar_left(s, color, width, base): + """ + The minimum value is aligned at the left of the cell - base = 'width: 10em; height: 80%;' - attrs = (base + 'background: linear-gradient(90deg,{c} {w}%, ' + 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()) + 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, align='left', axis=0, + color='#d65f5f', width=100): """ Color the background ``color`` proptional to the values in each column. Excludes non-numeric data by default. - .. versionadded:: 0.17.1 Parameters @@ -877,10 +983,23 @@ def bar(self, subset=None, axis=0, color='#d65f5f', width=100): 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'} + + .. versionadded:: 0.20.0 + + - '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 Returns ------- @@ -888,8 +1007,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/formats/test_style.py b/pandas/tests/formats/test_style.py index 44af0b8ebb085..a1768fac47acc 100644 --- a/pandas/tests/formats/test_style.py +++ b/pandas/tests/formats/test_style.py @@ -265,7 +265,7 @@ def test_empty(self): {'props': [['', '']], 'selector': 'row1_col0'}] self.assertEqual(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 = { @@ -298,7 +298,7 @@ def test_bar(self): result = df.style.bar(color='red', width=50)._compute().ctx self.assertEqual(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%'], @@ -348,6 +348,115 @@ def test_bar_0points(self): ', transparent 0%)']} self.assertEqual(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%)']} + self.assertEqual(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%)']} + self.assertEqual(result, expected) + + def test_bar_bad_align_raises(self): + df = pd.DataFrame({'A': [-100, -60, -30, -20]}) + with tm.assertRaises(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