diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29bb..308c11e76 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,4 @@ +- bump: minor + changes: + added: + - Chart formatting utilities. diff --git a/docs/_config.yml b/docs/_config.yml index 3dc527a31..4bec1e9dd 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,9 +1,10 @@ -title: PolicyEngine Core documentation +title: PolicyEngine Core author: PolicyEngine copyright: "2022" +logo: logo.png execute: - execute_notebooks: "force" + execute_notebooks: off repository: url: https://github.com/policyengine/policyengine-core @@ -13,9 +14,11 @@ repository: sphinx: config: html_js_files: - - https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js + - https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.js html_theme: furo pygments_style: default + html_css_files: + - style.css extra_extensions: - "sphinx.ext.autodoc" - "sphinxarg.ext" diff --git a/docs/_static/style.css b/docs/_static/style.css new file mode 100644 index 000000000..2a7a0ae4c --- /dev/null +++ b/docs/_static/style.css @@ -0,0 +1,9 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto+Serif:opsz@8..144&family=Roboto:wght@300&display=swap'); + +h1, h2, h3, h4, h5, h6 { + font-family: "Roboto"; +} + +body { + font-family: "Roboto Serif"; +} \ No newline at end of file diff --git a/docs/_toc.yml b/docs/_toc.yml index aeb2b177b..c909f933e 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -12,6 +12,7 @@ parts: - file: usage/parameters - file: usage/datasets - file: usage/reforms + - file: usage/charts - caption: Python API chapters: - file: python_api/commons diff --git a/docs/contributing/intro.md b/docs/contributing/intro.md index 427fae4f0..700a0f735 100644 --- a/docs/contributing/intro.md +++ b/docs/contributing/intro.md @@ -5,7 +5,7 @@ Any and all contributions are welcome to this project. You can help by: * Filing issues. Tell us about bugs you've found, or features you'd like to see. * Fixing issues. File a pull request to fix an issue you or someone else has filed. -If you file an issue or a pull request, one of the maintainers (primarily @nikhilwoodruff) will respond to it within at least a week. If you don't hear back, feel free to ping us on the issue or pull request. +If you file an issue or a pull request, one of the maintainers (primarily [@nikhilwoodruff](https://github.com/nikhilwoodruff)) will respond to it within at least a week. If you don't hear back, feel free to ping us on the issue or pull request. ## Changelog Entries diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 000000000..12736e4dc Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/usage/charts.ipynb b/docs/usage/charts.ipynb new file mode 100644 index 000000000..3577bb4b0 --- /dev/null +++ b/docs/usage/charts.ipynb @@ -0,0 +1,181 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Charts\n", + "\n", + "PolicyEngine Core provides a set of chart utils to speed up data visualisation for PolicyEngine model-powered analyses. These use the PolicyEngine styling by default. The examples below use the PolicyEngine UK microsimulation model.\n", + "\n", + "## Bar chart\n", + "\n", + "The `bar_chart` function creates a bar chart from a dataframe." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "
" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Reform code generated from the PolicyEngine export function.\n", + "\n", + "from policyengine_uk import Microsimulation\n", + "from policyengine_core.reforms import Reform\n", + "from policyengine_core.periods import instant\n", + "\n", + "\n", + "def modify_parameters(parameters):\n", + " parameters.gov.hmrc.income_tax.rates.uk[0].rate.update(\n", + " start=instant(\"2023-01-01\"), stop=instant(\"2028-12-31\"), value=0.25\n", + " )\n", + " return parameters\n", + "\n", + "\n", + "class reform(Reform):\n", + " def apply(self):\n", + " self.modify_parameters(modify_parameters)\n", + "\n", + "\n", + "baseline = Microsimulation()\n", + "reformed = Microsimulation(reform=reform)\n", + "\n", + "baseline_income = baseline.calculate(\"household_net_income\", 2023)\n", + "reformed_income = reformed.calculate(\"household_net_income\", 2023)\n", + "gain = reformed_income - baseline_income\n", + "decile = baseline.calculate(\"household_income_decile\", 2023)\n", + "decile_impacts = (\n", + " gain.groupby(decile).sum() / baseline_income.groupby(decile).sum()\n", + ")\n", + "decile_impacts = decile_impacts[decile_impacts.index != 0]\n", + "\n", + "from policyengine_core.charts import *\n", + "\n", + "display_fig(\n", + " bar_chart(\n", + " decile_impacts,\n", + " title=\"Change in net income by decile\",\n", + " xaxis_title=\"Decile\",\n", + " yaxis_title=\"Change in net income\",\n", + " xaxis_tickvals=list(range(1, 11)),\n", + " yaxis_tickformat=\".0%\",\n", + " text_format=\".1%\",\n", + " hover_text_function=lambda x, y: f\"The {cardinal(x)} decile sees a {y:+.1%} in net income\",\n", + " )\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cross-section bar chart\n", + "\n", + "The cross-section bar chart is useful for showing the distribution of outcomes along different breakdowns." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "
" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "lower_age_group = baseline.calculate(\"age\", 2023) // 10\n", + "personal_gain = reformed.calculate(\n", + " \"household_net_income\", 2023, map_to=\"person\"\n", + ") - baseline.calculate(\"household_net_income\", 2023, map_to=\"person\")\n", + "personal_gain = personal_gain[lower_age_group < 8]\n", + "lower_age_group = lower_age_group[lower_age_group < 8] + 1\n", + "\n", + "display_fig(\n", + " cross_section_bar_chart(\n", + " personal_gain,\n", + " lower_age_group,\n", + " slices=[-0.1, -0.01, 0.01, 0.1],\n", + " xaxis_tickformat=\".0%\",\n", + " category_names=[\n", + " \"Lose more than 10%\",\n", + " \"Lose between 1% and 10%\",\n", + " \"Experience less than 1% change\",\n", + " \"Gain between 1% and 10%\",\n", + " \"Gain more than 10%\",\n", + " ],\n", + " yaxis_ticktext=[\n", + " \"Under 10\",\n", + " \"10 to 19\",\n", + " \"20 to 29\",\n", + " \"30 to 39\",\n", + " \"40 to 49\",\n", + " \"50 to 59\",\n", + " \"60 to 69\",\n", + " \"70 to 79\",\n", + " ],\n", + " color_discrete_map={\n", + " \"Lose more than 10%\": DARK_GRAY,\n", + " \"Lose between 1% and 10%\": MEDIUM_DARK_GRAY,\n", + " \"Experience less than 1% change\": GRAY,\n", + " \"Gain between 1% and 10%\": LIGHT_GRAY,\n", + " \"Gain more than 10%\": BLUE,\n", + " },\n", + " legend_orientation=\"h\",\n", + " legend_y=-0.2,\n", + " title=\"Gain by age\",\n", + " hover_text_function=lambda age, outcome, percent: f\"{percent:.1%} of {age * 10:.0f} to {(age + 1) * 10:.0f} year olds {outcome.lower()} of their income\",\n", + " )\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/policyengine_core/charts/__init__.py b/policyengine_core/charts/__init__.py index 1b11ab93c..2980b5a89 100644 --- a/policyengine_core/charts/__init__.py +++ b/policyengine_core/charts/__init__.py @@ -10,3 +10,5 @@ format_fig, display_fig, ) + +from .bar import * diff --git a/policyengine_core/charts/bar.py b/policyengine_core/charts/bar.py new file mode 100644 index 000000000..bd3366df5 --- /dev/null +++ b/policyengine_core/charts/bar.py @@ -0,0 +1,133 @@ +import pandas as pd +from .formatting import * +import plotly.express as px +from microdf import MicroSeries +from typing import Callable +import numpy as np + + +def bar_chart( + data: pd.Series, + showlegend: bool = False, + remove_zero_index: bool = True, + text_format: str = "+.1%", + positive_colour: str = BLUE, + negative_colour: str = DARK_GRAY, + hover_text_function: Callable = None, + **kwargs, +): + """Create a PolicyEngine bar chart. + + Args: + data (pd.Series): A pandas series. + showlegend (bool, optional): Whether to show the legend. Defaults to False. + remove_zero_index (bool, optional): Whether to remove the zero index. Defaults to True. + text_format (str, optional): The format of the text. Defaults to "+.1%". + positive_colour (str, optional): The colour of positive values. Defaults to BLUE. + negative_colour (str, optional): The colour of negative values. Defaults to DARK_GRAY. + hover_text_labels (list, optional): The hover text labels. Defaults to None. + + Returns: + go.Figure: A plotly figure. + """ + + hover_text_labels = [ + hover_text_function(index, value) + if hover_text_function is not None + else None + for index, value in data.items() + ] + + fig = ( + px.bar( + data, + text=data.apply(lambda x: f"{x:{text_format}}"), + custom_data=[hover_text_labels] if hover_text_labels else None, + ) + .update_layout( + showlegend=showlegend, + uniformtext=dict( + mode="hide", + minsize=12, + ), + **kwargs, + ) + .update_traces( + marker_color=[ + positive_colour if v > 0 else negative_colour + for v in data.values + ], + hovertemplate="%{customdata[0]}" + if hover_text_labels is not None + else None, + ) + ) + return format_fig(fig) + + +def cross_section_bar_chart( + data: MicroSeries, + cross_section: MicroSeries, + slices: list = [-0.05, -0.01, 0.01, 0.05], + add_infinities: bool = True, + text_format: str = ".1%", + hover_text_function: Callable = None, + category_names=None, + color_discrete_map: dict = None, + **kwargs, +): + df = pd.DataFrame() + slices = [-np.inf, *slices, np.inf] if add_infinities else slices + for i, lower, upper in zip(range(len(slices)), slices[:-1], slices[1:]): + for cross_section_value in cross_section.unique(): + in_slice = (data >= lower) * (data < upper) + value = ( + data[cross_section == cross_section_value][in_slice].count() + / data[cross_section == cross_section_value].count() + ) + category = ( + category_names[i] + if category_names is not None + else f"{lower:.0%} to {upper:.0%}" + ) + row = { + "Category": category, + "Cross section": cross_section_value, + "Value": value, + "Hover text": hover_text_function( + cross_section_value, category, value + ) + if hover_text_function is not None + else None, + } + df = df.append(row, ignore_index=True) + + fig = ( + px.bar( + df, + x="Value", + y="Cross section", + color="Category", + barmode="stack", + orientation="h", + text=df["Value"].apply(lambda x: f"{x:{text_format}}"), + color_discrete_map=color_discrete_map, + custom_data=["Hover text"], + ) + .update_layout( + xaxis=dict( + tickformat="%", + title="Fraction of observations", + ), + yaxis=dict(tickvals=list(range(1, 11))), + uniformtext=dict( + mode="hide", + minsize=12, + ), + **kwargs, + ) + .update_traces( + hovertemplate="%{customdata[0]}", + ) + ) + return format_fig(fig) diff --git a/policyengine_core/charts/formatting.py b/policyengine_core/charts/formatting.py index c2fee8aec..b71997053 100644 --- a/policyengine_core/charts/formatting.py +++ b/policyengine_core/charts/formatting.py @@ -50,12 +50,6 @@ def format_fig(fig: go.Figure) -> go.Figure: template="plotly_white", height=600, width=800, - margin=dict( - t=100, - b=100, - l=100, - r=100, - ), ) # don't show modebar fig.update_layout( @@ -71,3 +65,16 @@ def display_fig(fig: go.Figure) -> HTML: return HTML( format_fig(fig).to_html(full_html=False, include_plotlyjs="cdn") ) + + +def cardinal(n: int) -> int: + """Convert an integer to a cardinal string.""" + ending_number = n % 10 + if ending_number == 1: + return f"{n}st" + elif ending_number == 2: + return f"{n}nd" + elif ending_number == 3: + return f"{n}rd" + else: + return f"{n}th"