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": [
+ "
"
+ ],
+ "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": [
+ ""
+ ],
+ "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"