From b4449036684acdad95f5eac0314994c1e7dacedc Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Fri, 12 Jul 2019 15:16:24 -0400 Subject: [PATCH 1/5] enhance report capabilities --- MANIFEST.in | 1 + environment.yml | 8 + taxbrain/__init__.py | 2 + taxbrain/report.py | 187 ++++++++++ taxbrain/report_files/report_style.css | 91 +++++ taxbrain/report_files/report_template.md | 134 +++++++ taxbrain/report_files/taxbrain.png | Bin 0 -> 15833 bytes taxbrain/report_utils.py | 448 +++++++++++++++++++++++ taxbrain/utils.py | 139 +++++++ 9 files changed, 1010 insertions(+) create mode 100644 taxbrain/report.py create mode 100644 taxbrain/report_files/report_style.css create mode 100644 taxbrain/report_files/report_template.md create mode 100644 taxbrain/report_files/taxbrain.png create mode 100644 taxbrain/report_utils.py diff --git a/MANIFEST.in b/MANIFEST.in index e69de29..ff5c492 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -0,0 +1 @@ +include taxbrain/report_text/report_template.md \ No newline at end of file diff --git a/environment.yml b/environment.yml index dbd845f..d74bd84 100644 --- a/environment.yml +++ b/environment.yml @@ -1,6 +1,7 @@ name: taxbrain-dev channels: - PSLmodels +- conda-forge dependencies: - python>=3.6.5 - taxcalc>=2.2.0 @@ -10,5 +11,12 @@ dependencies: - pytest - dask - bokeh +- markdown +- cairo +- pango +- phantomjs - pip: - compdevkit + - tabulate + - weasyprint + - selenium diff --git a/taxbrain/__init__.py b/taxbrain/__init__.py index e862e53..ad0283d 100644 --- a/taxbrain/__init__.py +++ b/taxbrain/__init__.py @@ -1,5 +1,7 @@ from taxbrain.taxbrain import * from taxbrain.utils import * from taxbrain.cli import * +from taxbrain.report import * +from taxbrain.report_utils import * __version__ = "0.0.0" diff --git a/taxbrain/report.py b/taxbrain/report.py new file mode 100644 index 0000000..f92086d --- /dev/null +++ b/taxbrain/report.py @@ -0,0 +1,187 @@ +import behresp +import taxbrain +import taxcalc as tc +import cairocffi as cairo +from pathlib import Path +from bokeh.io import export_png, export_svgs +from .report_utils import (form_intro, form_baseline_intro, write_text, date, + largest_tax_change, notable_changes, + behavioral_assumptions, consumption_assumptions, + policy_table, convert_table, growth_assumptions, + md_to_pdf) + + +CUR_PATH = Path(__file__).resolve().parent + + +def report(tb, name=None, change_threshold=0.05, description=None, + outdir=None, author="", + verbose=False): + """ + Create a PDF report based on TaxBrain results + """ + def format_table(df): + """ + Apply formatting to a given table + """ + for col in df.columns: + df.update( + df[col].astype(float).apply("{:,.2f}".format) + ) + return df + + def export_plot(plot, graph): + """ + Export a bokeh plot based on Cairo version + """ + # export graph as a PNG or SVG depending on Cairo version is installed + # higher quality SVG only works with Cairo version >= 1.15.4 + cairo_version = cairo.cairo_version() + if cairo_version < 11504: + filename = f"{graph}_graph.png" + full_filename = Path(output_path, filename) + export_png(plot, full_filename) + print("For a higher quality SVG image file, install Cairo 1.15.4") + else: + filename = f"{graph}_graph.svg" + full_filename = Path(output_path, filename) + export_svgs(plot, full_filename) + + return filename + + if not name: + name = f"Policy Report-{date()}" + if not outdir: + outdir = "-".join(name) + # create directory to hold report contents + output_path = Path(outdir) + output_path.mkdir() + # dictionary to hold pieces of the final text + text_args = { + "start_year": tb.start_year, + "end_year": tb.end_year, + "title": name, + "date": date(), + "author": author + } + if verbose: + print("Writing Introduction") + # find policy areas used in the reform + pol = tc.Policy() + pol_meta = pol.metadata() + pol_areas = set() + for var in tb.params["policy"].keys(): + area = pol_meta[var]["section_1"] + if area != "": + pol_areas.add(area) + pol_areas = list(pol_areas) + # add policy areas to the intro text + text_args["introduction"] = form_intro(pol_areas, description) + # write final sentance of introduction + current_law = tb.params["base_policy"] + text_args["baseline_intro"] = form_baseline_intro(current_law) + + if verbose: + print("Writing Summary") + agg_table = tb.weighted_totals("combined") + rev_change = agg_table.loc["Difference"].sum() + rev_direction = "increase" + if rev_change < 0: + rev_direction = "decrease" + text_args["rev_direction"] = rev_direction + text_args["rev_change"] = f"{rev_change:,.0f}" + + # create differences table + diff_table = tb.differences_table( + tb.start_year, "standard_income_bins", "combined" + ) + # find which income bin sees the largest change in tax liability + largest_change = largest_tax_change(diff_table) + text_args["largest_change_group"] = largest_change[0] + text_args["largest_change_str"] = largest_change[1] + diff_table.columns = tc.DIFF_TABLE_LABELS + # drop certain columns to save space + drop_cols = [ + "Share of Overall Change", "Count with Tax Cut", + "Count with Tax Increase" + ] + sub_diff_table = diff_table.drop(columns=drop_cols) + + # convert DataFrame to Markdown table + diff_table.index.name = "_Income Bin_" + # apply formatting + diff_table = format_table(diff_table) + diff_md = convert_table(sub_diff_table) + text_args["differences_table"] = diff_md + + # aggregate results + # format aggregate table + agg_table *= 1e-9 + agg_table = format_table(agg_table) + agg_md = convert_table(agg_table) + text_args["agg_table"] = agg_md + + # aggregate table by tax type + tax_vars = ["iitax", "payrolltax", "combined"] + agg_base = tb.multi_var_table(tax_vars, "base") + agg_reform = tb.multi_var_table(tax_vars, "reform") + agg_diff = agg_reform - agg_base + agg_diff.index = ["Income Tax", "Payroll Tax", "Combined"] + agg_diff *= 1e-9 + agg_diff = format_table(agg_diff) + text_args["agg_tax_type"] = convert_table(agg_diff) + + # summary of policy changes + text_args["reform_summary"] = policy_table(tb.params["policy"]) + + # policy baseline + if tb.params["base_policy"]: + text_args["policy_baseline"] = policy_table(tb.params["base_policy"]) + else: + text_args["policy_baseline"] = ( + f"This report is based on current law as of {date()}." + ) + + # notable changes + text_args["notable_changes"] = notable_changes(tb, change_threshold) + + # behavioral assumptions + text_args["behavior_assumps"] = behavioral_assumptions(tb) + # consumption asssumptions + text_args["consump_assumps"] = consumption_assumptions(tb) + # growth assumptions + text_args["growth_assumps"] = growth_assumptions(tb) + + # determine model versions + text_args["model_versions"] = [ + {"name": "Tax-Brain", "release": taxbrain.__version__}, + {"name": "Tax-Calculator", "release": tc.__version__}, + {"name": "Behavioral-Responses", "release": behresp.__version__} + ] + + # create graphs + dist_graph = taxbrain.distribution_plot(tb, tb.start_year, width=650) + dist_graph.background_fill_color = None + dist_graph.border_fill_color = None + text_args["distribution_graph"] = export_plot(dist_graph, "dist") + + # differences graph + diff_graph = taxbrain.differences_plot(tb, "combined", width=640) + diff_graph.background_fill_color = None + diff_graph.border_fill_color = None + text_args["agg_graph"] = export_plot(diff_graph, "difference") + + # fill in the report template + template_path = Path(CUR_PATH, "report_files", "report_template.md") + report_md = write_text(template_path, **text_args) + + # create PDF and HTML used to create the PDF + wpdf, html = md_to_pdf(report_md, str(output_path)) + # write PDF, markdown files, HTML + filename = name.replace(" ", "-") + pdf_path = Path(output_path, f"{filename}.pdf") + pdf_path.write_bytes(wpdf) + md_path = Path(output_path, f"{filename}.md") + md_path.write_text(report_md) + html_path = Path(output_path, "output.html") + html_path.write_text(html) diff --git a/taxbrain/report_files/report_style.css b/taxbrain/report_files/report_style.css new file mode 100644 index 0000000..cbfe45e --- /dev/null +++ b/taxbrain/report_files/report_style.css @@ -0,0 +1,91 @@ +/* page formatting */ +@page { + @bottom-right { + content: counter(page) + } +} +h2 { + text-align: center; +} +/* cover page */ +@page :first { + @top-right { + content: url(taxbrain.png); + margin-top: 5%; + } +} +#title { + left: 0; + line-height: 200px; + margin-top: -250px; + position: absolute; + text-align: center; + top: 50%; + width: 100%; +} +#author { + left: 0; + line-height: 200px; + margin-top: -200px; + position: absolute; + text-align: center; + top: 50%; + width: 100%; +} +#date { + left: 0; + line-height: 200px; + margin-top: -150px; + position: absolute; + text-align: center; + top: 50%; + width: 100%; +} +/* table of contents */ +/* link formatting */ +#contents h3 a { + color: rgb(53, 50, 50); +} +#contents li a { + color: rgb(83, 79, 79); +} +#contents a { + text-decoration: none; +} +/* table formatting */ +table { + border: 0.5px solid; + border-color: black; + text-align: center; + margin-left: auto; + margin-right: auto; +} +tr { + font-size: 12px; + border-color: black; + border-right: 2px dashed; + padding-right: 0%; +} +tbody tr:nth-child(odd) { + background-color: rgb(199, 194, 194); +} +th { + font-size: 14px; + border-bottom: 1px solid; + border-right: 1px dashed; +} +td { + border-right: 1px dashed; + padding: 5px; +} +/* set the space around article sections */ +article { + align-content: space-between; +} + +article #cover { + text-align: center; + align-content: center; + display: flex; + flex-wrap: wrap; +} \ No newline at end of file diff --git a/taxbrain/report_files/report_template.md b/taxbrain/report_files/report_template.md new file mode 100644 index 0000000..f9563a4 --- /dev/null +++ b/taxbrain/report_files/report_template.md @@ -0,0 +1,134 @@ +~article id="cover" + +# {{ title}} {: #title} + +### {{ author }} {: #author} +{{ date }} +{: #date} + +~/article + +~article id="contents" + +## Table of Contents + +### [Introduction](#introduction-title) +* [Analysis Summary](#summary-title) +* [Notable Changes](#notable-title) +### [Aggregate Changes](#aggregate-title) +### [Distributional Analysis](#distributional-title) +### [Summary of Policy Changes](#policychange-title) +### [Baseline Policy](#baseline-title) +### [Assumptions](#assumptions-title) +* [Behavioral Assumptions](#behavior-title) +* [Consumption Assumptions](#consumption-title) +* [Growth Assumptions](#growth-title) +### [Citations](#citations-title) + +~/article + +~article id="introduction" + +## Introduction + +This report summarizes the fiscal impact of {{ introduction }}. The baseline for this analysis is current law as of {{ baseline_intro }}. + +## Summary {: #summary} + +Over the budget window ({{ start_year }} to {{ end_year }}), this policy is expected to {{ rev_direction }} aggregate tax liability by ${{ rev_change }}. Those with expanded income {{ largest_change_group}} are expected to see the largest change in tax liability. On average, this group can expect to see their tax liability {{ largest_change_str }}. + +{{ ati_change_graph }} + +## Notable Changes {: #notable} + +{% for change in notable_changes %} +* {{ change }} +{% endfor %} + +~/article + +~article id="aggregate" + +## Aggregate Changes + +#### Total Tax Liability Change (Billions) + +{{ agg_table }} + +#### Total Tax Liability Change by Tax Type (Billions) + +{{ agg_tax_type }} + + + +~/article + +~article id="distributional" + +## Distributional Analysis + +{{ differences_table }} + + + +~/article + +~article id="policychange" + +## Summary of Policy Changes {: #policysummary} + +{% for year, summary in reform_summary.items() %} +_{{ year }}_ + +{{ summary }} +{% endfor %} + +~/article + +~article id="baseline" + +## Policy Baseline + +{{ policy_baseline }} + +~/article + +~article id="assumptions" + +## Assumptions + +### Behavioral Assumptions {: #behavior} + +{% for item in behavior_assumps %} +* {{item}} +{% endfor %} + +### Consumption Assumptions {: #consumption} + +{% for year, summary in consump_assumps.items() %} +{{ year }} + +{{ summary }} +{% endfor %} + +### Growth Assumptions {: #growth} + +{% for year, summary in growth_assumps.items() %} +{{ year }} + +{{ summary }} +{% endfor %} + +~/article + +~article id="citations" + +## Citations + +This analysis was conducted using the following open source economic models: + +{% for model in model_versions %} +* {{ model.name }} release {{ model.release }} +{% endfor %} + +~/article \ No newline at end of file diff --git a/taxbrain/report_files/taxbrain.png b/taxbrain/report_files/taxbrain.png new file mode 100644 index 0000000000000000000000000000000000000000..5810298fe045de90de4278452e1d94d7a2bc5948 GIT binary patch literal 15833 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" + # append the line to the list that will be used to create final HTML + final_html_list.append(line) + # join the HTML on the new line character before converting to PDF + joined_html = "\n".join(final_html_list) + # also insert proper article end tags + # adding page-break-before style ensures sections are on different pages + joined_html = joined_html.replace( + "

~/article

", + '\n

' + ) + css_path = Path(CUR_PATH, "report_files", "report_style.css") + css = weasyprint.CSS(filename=css_path) + wpdf = weasyprint.HTML( + string=joined_html, base_url=base_url + ).write_pdf(stylesheets=[css]) + + return wpdf, joined_html + + +def convert_table(df): + """ + Convert pandas DataFrame to Markdown style table + """ + if isinstance(df, pd.DataFrame): + return tabulate( + df, headers="keys", tablefmt="pipe" + ) + else: + return tabulate( + df, headers="firstrow", tablefmt="pipe" + ) + + +def policy_table(params): + """ + Create a table showing the policy parameters in a reform and their + default value + """ + # map out additional name information for vi indexed variables + vi_map = { + "MARS": [ + "Single", "Married Filing Jointly", + "Married Filing Separately", "Head of Household", "Widow" + ], + "idedtype": [ + "Medical", "State & Local Taxes", "Real EState Taxes", + "Casualty", "Miscellaneous", "Interest Paid", + "Charitable Giving" + ], + "EIC": [ + "0 Kids", "1 Kid", "2 Kids", "3+ Kids" + ] + } + reform_years = set() + reform_by_year = defaultdict(lambda: deque()) + pol = tc.Policy() # policy object used for getting original value + # loop through all of the policy parameters in a given reform + for param, meta in params.items(): + # find all the years the parameter is updated + years = set(meta.keys()) + reform_years = reform_years.union(years) + for yr in years: + # find default information + pol.set_year(yr) + pol_meta = pol.metadata()[param] + name = pol_meta["long_name"] + default_val = pol_meta["value"] + new_val = meta[yr] + # skip any duplicated policy parameters + if default_val == new_val: + continue + # create individual lines for indexed parameters + if isinstance(default_val, list): + vi_list = vi_map[pol_meta["vi_name"]] + for i, val in enumerate(default_val): + _name = f"{name} - {vi_list[i]}" + _default_val = f"{val:,}" + _new_val = f"{new_val[i]:,}" + reform_by_year[yr].append( + [_name, _default_val, _new_val] + ) + else: + reform_by_year[yr].append( + [name, f"{default_val:,}", f"{new_val:,}"] + ) + + # convert all tables from CSV format to Markdown + md_tables = {} + for yr in reform_years: + content = reform_by_year[yr] + content.appendleft( + ["Policy", "Original Value", "New Value"] + ) + md_tables[yr] = convert_table(content) + + return md_tables + + +def write_text(template_path, **kwargs): + """ + Fill in text with specified template + """ + template_str = Path(template_path).open("r").read() + template = Template(template_str) + rendered = template.render(**kwargs) + + return rendered + + +def date(): + """ + Return formatted date + """ + today = datetime.today() + month = today.strftime("%B") + day = today.day + year = today.year + date = f"{month} {day}, {year}" + return date + + +def form_intro(pol_areas, description): + """ + Form the introduction line + """ + # these are all of the possible strings used in the introduction sentance + intro_text = { + 1: "modifing the {} section of the tax code", + 2: "modifing the {} and {} sections of the tax code", + 3: "modifing the {}, {}, and {} sections of the tax code", + 4: ("modifing a number of sections of the tax code, " + "including {}, {}, and {}") + } + if not description: + num_areas = min(len(pol_areas), 4) + intro_line = intro_text[num_areas] + if num_areas == 1: + return intro_line.format(pol_areas[0]) + elif num_areas == 2: + return intro_line.format(pol_areas[0], pol_areas[1]) + else: + return intro_line.format(pol_areas[0], pol_areas[1], pol_areas[2]) + else: + return description + + +def form_baseline_intro(current_law): + """ + Form final sentance of introduction paragraph + """ + if not current_law: + return f"{date()}" + else: + return ( + f"{date()}, along with some modifications. A summary of these " + "modifications can be found in the \"Summary of Baseline Policy\" " + "section" + ) + + +def largest_tax_change(diff): + """ + Function to find the largest change in tax liability + """ + sub_diff = diff.drop(index="ALL") # remove total row + # find the absolute largest change in total liability + absolute_change = abs(sub_diff["tot_change"]) + largest = sub_diff[ + max(absolute_change) == sub_diff["tot_change"] + ] + index_largest = largest.index.values[0] + largest_change = largest["mean"].values[0] # index in case there"s a tie + # split index to form sentance + split_index = index_largest.split("-") + if len(split_index) == 1: + index_name = split_index[0][1:] + direction = split_index[0][0] + if direction == ">": + direction_str = "greater than" + elif direction == "<": + direction_str = "less than" + else: + direction_str = "equal to" + largest_change_group = f"{direction_str} {index_name}" + else: + largest_change_group = f"between {split_index[0]} and {split_index[1]}" + + if largest_change < 0: + largest_change_str = f"decrease by ${largest_change:,.2f}" + elif largest_change > 0: + largest_change_str = f"increase by ${largest_change:,.2f}" + else: + largest_change_str = f"remain the same" + + return largest_change_group, largest_change_str + + +def notable_changes(tb, threshold): + """ + Find any notable changes in certain variables. "Notable" is definded as a + percentage change above the given threshold. + """ + notable_list = [] + # loop through all of the notable variables and see if there is a year + # where they change more than the given threshold + for var, desc in notable_vars.items(): + if var.startswith("count_"): + # count number of filers with a non-zero value for a given variable + totals = [] + years = [] + for year in range(tb.start_year, tb.end_year + 1): + _var = var.split("_")[1] + base = tb.base_data[year] + reform = tb.reform_data[year] + base_total = np.where( + base[_var] != 0, base["s006"], 0 + ).sum() + reform_total = np.where( + reform[_var] != 0, reform["s006"], 0 + ).sum() + diff = reform_total - base_total + totals.append( + {"Base": base_total, "Reform": reform_total, + "Difference": diff} + ) + years.append(year) + totals = pd.DataFrame(totals) + totals.index = years + else: + totals = tb.weighted_totals(var).transpose() + totals["pct_change"] = totals["Difference"] / totals["Base"] + totals = totals.fillna(0.) + max_pct_change = max(totals["pct_change"]) + max_yr = totals[totals["pct_change"] == max_pct_change].index.values[0] + if abs(max_pct_change) >= threshold: + if max_pct_change < 0: + direction = "decreases" + else: + direction = "increases" + pct_chng = max_pct_change * 100 + notable_str = f"{desc} {direction} by {pct_chng:.2f}% in {max_yr}" + notable_list.append(notable_str) + # add default message if no notable changes + if len(notable_list) == 0: + min_chng = threshold * 100 + msg = f"No notable variables changed by more than {min_chng:.2f}%" + notable_list.append(msg) + return notable_list + + +def behavioral_assumptions(tb): + """ + Return list of behavioral assumptions used + """ + behavior_map = { + "sub": "Substitution elasticity of taxable income: {}", + "inc": "Income elasticity of taxable income: {}", + "cg": "Semi-elasticity of long-term capital gains: {}" + } + assumptions = [] + # if there are some behavioral assumptions, loop through them + if tb.params["behavior"]: + for param, val in tb.params["behavior"].items(): + assumptions.append( + behavior_map[param].format(val) + ) + else: + assumptions.append("No behavioral assumptions") + return assumptions + + +def consumption_assumptions(tb): + """ + Create table of consumption assumptions used in analysis + """ + if tb.params["consumption"]: + params = tb.params["consumption"] + # create table of consumption assumptions + consump_years = set() + consump_by_year = defaultdict(lambda: deque()) + # read consumption.json from taxcalc package + consump_json = Path( + Path(tc.__file__).resolve().parent, "consumption.json" + ).open("r").read() + consump_meta = json.loads(consump_json) + for param, meta in params.items(): + # find all the years the parameter is updated + years = set(meta.keys()) + consump_years = consump_years.union(years) + name = consump_meta[param]["long_name"] + default_val = consump_meta[param]["value"][0] + for yr in years: + # find default information + new_val = meta[yr] + consump_by_year[yr].append( + [name, default_val, new_val] + ) + md_tables = {} # hold markdown version of the tables + for yr in consump_years: + content = consump_by_year[yr] + content.appendleft( + ["", "Default Value", "User Value"] + ) + md_tables[yr] = convert_table(content) + return md_tables + else: + msg = "No new consumption assumptions specified." + return {"": msg} + + +def growth_assumptions(tb): + """ + Create a table with all of the growth assumptions used in the analysis + """ + growth_vars_map = { + "ABOOK": "General Business and Foreign Tax Credit Growth Rate", + "ACGNS": "Capital Gains Growth Rate", + "ACPIM": "CPI - Medical", + "ACPIU": "CPI - Urban Consumer", + "ADIV": "Dividend Income Growth Rate", + "AINTS": "Interest Income Growth Rate", + "AIPD": "Interest Paid Deduction Growth Rate", + "ASCHCI": "Schedule C Income Growth Rate", + "ASCHCL": "Schedule C Losses Growth Rate", + "ASCHEI": "Schedule E Income Growth Rate", + "ASCHEL": "Schedule E Losses Growth Rate", + "ASCHFI": "Schedule F Income Growth Rate", + "ASCHFL": "Schedule F Losses Growth Rate", + "ASOCSEC": "Social Security Benefit Growth Rate", + "ATXPY": "Personal Income Growth Rate", + "AUCOMP": "Unemployment Compensation Growth Rate", + "AWAGE": "Wage Income Growth Rate", + "ABENOTHER": "Other Benefits Growth Rate", + "ABENMCARE": "Medicare Benefits Growth Rate", + "ABENMCAID": "Medicaid Benfits Growth Rate", + "ABENSSI": "SSI Benefits Growth Rate", + "ABENSNAP": "SNAP Benefits Growth Rate", + "ABENWIC": "WIC Benfits Growth Rate", + "ABENHOUSING": "Housing Benefits Growth Rate", + "ABENTANF": "TANF Benfits Growth Rates", + "ABENVET": "Veteran's Benfits Growth Rates" + } + if tb.params["growdiff_response"]: + params = tb.params["growdiff_response"] + growdiff_years = set() + growdiff_by_year = defaultdict(lambda: deque()) + + # base GrowFactor object to pull default values + base_gf = tc.GrowFactors() + # create GrowDiff and GrowFactors for the new assumptions + reform_gd = tc.GrowDiff() + reform_gd.update_growdiff(params) + reform_gf = tc.GrowFactors() + reform_gd.apply_to(reform_gf) + # loop through all of the reforms + for param, meta in params.items(): + # find all years a new value is specified + years = set(meta.keys()) + growdiff_years = growdiff_years.union(years) + name = growth_vars_map[param] + for yr in years: + # find default and new values + default_val = base_gf.factor_value(param, yr) + new_val = reform_gf.factor_value(param, yr) + growdiff_by_year.append( + [name, default_val, new_val] + ) + + # create tables + md_tables = {} + for yr in growdiff_years: + content = growdiff_by_year[yr] + content.appendleft( + ["", "Default Value", "New Value"] + ) + md_tables[yr] = convert_table(content) + + return md_tables + + else: + return {"": "No new growth assumptions specified."} diff --git a/taxbrain/utils.py b/taxbrain/utils.py index d3fa4b5..e754887 100644 --- a/taxbrain/utils.py +++ b/taxbrain/utils.py @@ -1,6 +1,12 @@ """ Helper functions for the various taxbrain modules """ +import pandas as pd +import numpy as np +from bokeh.plotting import figure +from bokeh.models import ColumnDataSource, NumeralTickFormatter +from bokeh.palettes import GnBu5 +from collections import defaultdict def weighted_sum(df, var, wt="s006"): @@ -8,3 +14,136 @@ def weighted_sum(df, var, wt="s006"): Return the weighted sum of specified variable """ return (df[var] * df[wt]).sum() + + +def distribution_plot(tb, year, width=500, height=400): + """ + Create a horizontal bar chart to display the distributional change in + after tax income + """ + def find_percs(data, group): + """ + Find the percentage of people in the data set that saw + their income change by the given percentages + """ + pop = data["s006"].sum() + large_pos_chng = data["s006"][data["pct_change"] > 5].sum() / pop + small_pos_chng = data["s006"][(data["pct_change"] <= 5) & + (data["pct_change"] > 1)].sum() / pop + small_chng = data["s006"][(data["pct_change"] <= 1) & + (data["pct_change"] >= -1)].sum() / pop + small_neg_change = data["s006"][(data["pct_change"] < -1) & + (data["pct_change"] > -5)].sum() / pop + large_neg_change = data["s006"][data["pct_change"] < -5].sum() / pop + + return ( + large_pos_chng, small_pos_chng, small_chng, small_neg_change, + large_neg_change + ) + + # extract needed data from the TaxBrain object + ati_data = pd.DataFrame( + {"base": tb.base_data[year]["aftertax_income"], + "reform": tb.reform_data[year]["aftertax_income"], + "s006": tb.base_data[year]["s006"]} + ) + ati_data["diff"] = ati_data["reform"] - ati_data["base"] + ati_data["pct_change"] = (ati_data["diff"] / ati_data["base"]) * 100 + ati_data = ati_data.fillna(0.) # fill in NaNs for graphing + # group tupules: (low income, high income, income group name) + groups = [ + (-9e99, 9e99, "All"), + (1e6, 9e99, "$1M or More"), + (500000, 1e6, "$500K-1M"), + (200000, 500000, "$200K-500K"), + (100000, 200000, "$100K-200K"), + (75000, 100000, "$75K-100K"), + (50000, 75000, "$50K-75K"), + (40000, 50000, "$40K-50K"), + (30000, 40000, "$30K-40K"), + (20000, 30000, "$20K-30K"), + (10000, 20000, "$10K-20K"), + (-9e99, 10000, "Less than $10K") + ] + + plot_data = defaultdict(list) + # traverse list in reverse to get the axis of the plot in correct order + for low, high, grp in groups[:: -1]: + # find income changes by group + sub_data = ati_data[(ati_data["base"] <= high) & + (ati_data["base"] > low)] + results = find_percs(sub_data, grp) + plot_data["group"].append(grp) + plot_data["large_pos"].append(results[0]) + plot_data["small_pos"].append(results[1]) + plot_data["small"].append(results[2]) + plot_data["small_neg"].append(results[3]) + plot_data["large_neg"].append(results[4]) + + # groups used for plotting + change_groups = [ + "large_pos", "small_pos", "small", "small_neg", "large_neg" + ] + legend_labels = [ + "Increase of > 5%", "Increase 1-5%", "Change < 1%", + "Decrease of 1-5%", "Decrease > 5%" + ] + plot = figure( + y_range=plot_data["group"], x_range=(0, 1), toolbar_location=None, + width=width, height=height, + title=f"Percentage Change in After Tax Income - {year}" + ) + plot.hbar_stack( + change_groups, y="group", height=0.8, color=GnBu5, + source=ColumnDataSource(plot_data), + legend=legend_labels + ) + # general formatting + plot.yaxis.axis_label = "Expanded Income Bin" + plot.xaxis.axis_label = "Portion of Population" + plot.xaxis.formatter = NumeralTickFormatter(format="0%") + plot.xaxis.minor_tick_line_color = None + # move legend out of main plot area + legend = plot.legend[0] + plot.add_layout(legend, "right") + + return plot + + +def differences_plot(tb, tax_type, width=500, height=400): + """ + Create a bar chart that shows the change in total liability for a given + tax + """ + acceptable_taxes = ["income", "payroll", "combined"] + msg = f"tax_type must be one of the following: {acceptable_taxes}" + assert tax_type in acceptable_taxes, msg + + # find change in each tax variable + tax_vars = ["iitax", "payrolltax", "combined"] + agg_base = tb.multi_var_table(tax_vars, "base") + agg_reform = tb.multi_var_table(tax_vars, "reform") + agg_diff = agg_reform - agg_base + + # transpose agg_diff to make plotting easier + plot_data = agg_diff.transpose() + tax_var = tax_vars[acceptable_taxes.index(tax_type)] + plot_data["color"] = np.where(plot_data[tax_var] < 0, "red", "blue") + + plot = figure( + title=f"Change in Aggregate {tax_type.title()} Tax Liability", + width=width, height=height, toolbar_location=None + ) + plot.vbar( + x="index", bottom=0, top=tax_var, width=0.7, + source=ColumnDataSource(plot_data), + fill_color="color", line_color="color", + fill_alpha=0.7 + ) + # general formatting + plot.yaxis.formatter = NumeralTickFormatter(format="($0.00 a)") + plot.xaxis.formatter = NumeralTickFormatter(format="0") + plot.xaxis.minor_tick_line_color = None + plot.xgrid.grid_line_color = None + + return plot From 2dc1b4501247f6bff321c6dc05608dbef269210a Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Sat, 13 Jul 2019 14:34:21 -0400 Subject: [PATCH 2/5] fix tax change bug --- taxbrain/report_files/report_style.css | 4 ++-- taxbrain/report_utils.py | 2 +- taxbrain/utils.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/taxbrain/report_files/report_style.css b/taxbrain/report_files/report_style.css index cbfe45e..eabe35a 100644 --- a/taxbrain/report_files/report_style.css +++ b/taxbrain/report_files/report_style.css @@ -61,7 +61,7 @@ table { margin-right: auto; } tr { - font-size: 12px; + font-size: 11px; border-color: black; border-right: 2px dashed; padding-right: 0%; @@ -70,7 +70,7 @@ tbody tr:nth-child(odd) { background-color: rgb(199, 194, 194); } th { - font-size: 14px; + font-size: 12px; border-bottom: 1px solid; border-right: 1px dashed; } diff --git a/taxbrain/report_utils.py b/taxbrain/report_utils.py index 9bb7b64..2be509b 100644 --- a/taxbrain/report_utils.py +++ b/taxbrain/report_utils.py @@ -233,7 +233,7 @@ def largest_tax_change(diff): # find the absolute largest change in total liability absolute_change = abs(sub_diff["tot_change"]) largest = sub_diff[ - max(absolute_change) == sub_diff["tot_change"] + max(absolute_change) == absolute_change ] index_largest = largest.index.values[0] largest_change = largest["mean"].values[0] # index in case there"s a tie diff --git a/taxbrain/utils.py b/taxbrain/utils.py index e754887..7999505 100644 --- a/taxbrain/utils.py +++ b/taxbrain/utils.py @@ -138,7 +138,7 @@ def differences_plot(tb, tax_type, width=500, height=400): x="index", bottom=0, top=tax_var, width=0.7, source=ColumnDataSource(plot_data), fill_color="color", line_color="color", - fill_alpha=0.7 + fill_alpha=0.55 ) # general formatting plot.yaxis.formatter = NumeralTickFormatter(format="($0.00 a)") From 989393c0a156cc62a7cc0e83ce2cd0887e392472 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Sat, 13 Jul 2019 17:36:27 -0400 Subject: [PATCH 3/5] add tests --- taxbrain/cli.py | 27 ++++++++++-- taxbrain/report.py | 36 ++++++++++++++-- taxbrain/report_files/report_template.md | 24 +++++------ taxbrain/report_utils.py | 50 ++++++++++++++++++++-- taxbrain/taxbrain.py | 3 ++ taxbrain/tests/expected_weighted_table.csv | 6 +-- taxbrain/tests/test_report.py | 26 +++++++++++ 7 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 taxbrain/tests/test_report.py diff --git a/taxbrain/cli.py b/taxbrain/cli.py index 34d1738..ebd3870 100644 --- a/taxbrain/cli.py +++ b/taxbrain/cli.py @@ -2,7 +2,7 @@ Command line interface for the Tax-Brain package """ import argparse -from taxbrain import TaxBrain +from taxbrain import TaxBrain, report from pathlib import Path from datetime import datetime @@ -33,7 +33,7 @@ def make_tables(tb, year, outpath): def cli_core(startyear, endyear, data, usecps, reform, behavior, assump, - baseline, outdir, name): + baseline, outdir, name, make_report, author): """ Core logic for the CLI """ @@ -60,6 +60,11 @@ def cli_core(startyear, endyear, data, usecps, reform, behavior, assump, yeardir.mkdir() make_tables(tb, year, yeardir) + if make_report: + report( + tb, name=name, outdir=outputpath, author=author + ) + def cli_main(): """ @@ -152,12 +157,28 @@ def cli_main(): ), default=None ) + parser.add_argument( + "--report", + help=( + "including --report will trigger the creation of a PDF report " + "summarizing the effects of the tax policy being modeled." + ), + action="store_true" + ) + parser.add_argument( + "--author", + help=( + "If you are creating a report, this the name that will be listed " + "as the author" + ) + ) args = parser.parse_args() # run the analysis cli_core( args.startyear, args.endyear, args.data, args.usecps, args.reform, - args.behavior, args.assump, args.baseline, args.outdir, args.name + args.behavior, args.assump, args.baseline, args.outdir, args.name, + args.report ) diff --git a/taxbrain/report.py b/taxbrain/report.py index f92086d..a5f62d2 100644 --- a/taxbrain/report.py +++ b/taxbrain/report.py @@ -15,10 +15,23 @@ def report(tb, name=None, change_threshold=0.05, description=None, - outdir=None, author="", + outdir=None, author="", css=None, verbose=False): """ Create a PDF report based on TaxBrain results + + Parameters + ---------- + tb: TaxBrain object + name: Name you want used for the title of the report + change_threshold: Percentage change (expressed as a decimal fraction) in + an aggregate variable for it to be considered notable + description: A description of the reform being run + outdir: Output directory + author: Person or persons to be listed as the author of the report + css: Path to a CSS file used to format the final report + verbose: boolean indicating whether or not to write progress as report is + created """ def format_table(df): """ @@ -49,13 +62,16 @@ def export_plot(plot, graph): return filename + if not tb.has_run: + tb.run() if not name: name = f"Policy Report-{date()}" if not outdir: outdir = "-".join(name) # create directory to hold report contents output_path = Path(outdir) - output_path.mkdir() + if not output_path.exists(): + output_path.mkdir() # dictionary to hold pieces of the final text text_args = { "start_year": tb.start_year, @@ -92,6 +108,8 @@ def export_plot(plot, graph): text_args["rev_change"] = f"{rev_change:,.0f}" # create differences table + if verbose: + print("Creating distribution table") diff_table = tb.differences_table( tb.start_year, "standard_income_bins", "combined" ) @@ -115,6 +133,8 @@ def export_plot(plot, graph): text_args["differences_table"] = diff_md # aggregate results + if verbose: + print("Compiling aggregate results") # format aggregate table agg_table *= 1e-9 agg_table = format_table(agg_table) @@ -143,9 +163,13 @@ def export_plot(plot, graph): ) # notable changes + if verbose: + print("Finding notable changes") text_args["notable_changes"] = notable_changes(tb, change_threshold) # behavioral assumptions + if verbose: + print("Compiling assumptions") text_args["behavior_assumps"] = behavioral_assumptions(tb) # consumption asssumptions text_args["consump_assumps"] = consumption_assumptions(tb) @@ -160,6 +184,8 @@ def export_plot(plot, graph): ] # create graphs + if verbose: + print("Creating graphs") dist_graph = taxbrain.distribution_plot(tb, tb.start_year, width=650) dist_graph.background_fill_color = None dist_graph.border_fill_color = None @@ -172,16 +198,18 @@ def export_plot(plot, graph): text_args["agg_graph"] = export_plot(diff_graph, "difference") # fill in the report template + if verbose: + print("Compiling report") template_path = Path(CUR_PATH, "report_files", "report_template.md") report_md = write_text(template_path, **text_args) # create PDF and HTML used to create the PDF - wpdf, html = md_to_pdf(report_md, str(output_path)) + wpdf, html = md_to_pdf(report_md, str(output_path), css) # write PDF, markdown files, HTML filename = name.replace(" ", "-") pdf_path = Path(output_path, f"{filename}.pdf") pdf_path.write_bytes(wpdf) md_path = Path(output_path, f"{filename}.md") md_path.write_text(report_md) - html_path = Path(output_path, "output.html") + html_path = Path(output_path, f"{filename}.html") html_path.write_text(html) diff --git a/taxbrain/report_files/report_template.md b/taxbrain/report_files/report_template.md index f9563a4..72521b5 100644 --- a/taxbrain/report_files/report_template.md +++ b/taxbrain/report_files/report_template.md @@ -12,18 +12,18 @@ ## Table of Contents -### [Introduction](#introduction-title) -* [Analysis Summary](#summary-title) -* [Notable Changes](#notable-title) -### [Aggregate Changes](#aggregate-title) -### [Distributional Analysis](#distributional-title) -### [Summary of Policy Changes](#policychange-title) -### [Baseline Policy](#baseline-title) -### [Assumptions](#assumptions-title) -* [Behavioral Assumptions](#behavior-title) -* [Consumption Assumptions](#consumption-title) -* [Growth Assumptions](#growth-title) -### [Citations](#citations-title) +### [Introduction](#introduction) +* [Analysis Summary](#summary) +* [Notable Changes](#notable) +### [Aggregate Changes](#aggregate) +### [Distributional Analysis](#distributional) +### [Summary of Policy Changes](#policychange) +### [Baseline Policy](#baseline) +### [Assumptions](#assumptions) +* [Behavioral Assumptions](#behavior) +* [Consumption Assumptions](#consumption) +* [Growth Assumptions](#growth) +### [Citations](#citations) ~/article diff --git a/taxbrain/report_utils.py b/taxbrain/report_utils.py index 2be509b..a0bae6e 100644 --- a/taxbrain/report_utils.py +++ b/taxbrain/report_utils.py @@ -40,10 +40,20 @@ } -def md_to_pdf(md_text, base_url): +def md_to_pdf(md_text, base_url, css_path=None): """ Convert Markdown version of report to a PDF. Returns bytes that can be saved as a PDF + + Parameters + ---------- + md_text: report template written in markdown + base_url: path to the output directory + css_path: path to CSS file used for styling + + Returns + ------- + Bytes that can be saved as a PDF and the HTML used to create the report """ # try pandoc and weasyprint extention_str = "markdown.extensions.{}" @@ -75,7 +85,8 @@ def md_to_pdf(md_text, base_url): "

~/article

", '\n

' ) - css_path = Path(CUR_PATH, "report_files", "report_style.css") + if not css_path: + css_path = Path(CUR_PATH, "report_files", "report_style.css") css = weasyprint.CSS(filename=css_path) wpdf = weasyprint.HTML( string=joined_html, base_url=base_url @@ -87,6 +98,14 @@ def md_to_pdf(md_text, base_url): def convert_table(df): """ Convert pandas DataFrame to Markdown style table + + Parameters + ---------- + df: Pandas DataFrame + + Returns + ------- + String that is formatted as a markdown table """ if isinstance(df, pd.DataFrame): return tabulate( @@ -102,6 +121,15 @@ def policy_table(params): """ Create a table showing the policy parameters in a reform and their default value + + Parameters + ---------- + params: policy parameters being implemented + + Returns + ------- + String containing a markdown style table that summarized the given + parameters """ # map out additional name information for vi indexed variables vi_map = { @@ -186,9 +214,15 @@ def date(): return date -def form_intro(pol_areas, description): +def form_intro(pol_areas, description=None): """ Form the introduction line + + Parameters + ---------- + pol_areas: list of all the policy areas included in the reform used to + create a description of the reform + description: user provided description of the reform """ # these are all of the possible strings used in the introduction sentance intro_text = { @@ -228,6 +262,10 @@ def form_baseline_intro(current_law): def largest_tax_change(diff): """ Function to find the largest change in tax liability + + Parameters + ---------- + diff: Differences table created by taxbrain """ sub_diff = diff.drop(index="ALL") # remove total row # find the absolute largest change in total liability @@ -266,6 +304,12 @@ def notable_changes(tb, threshold): """ Find any notable changes in certain variables. "Notable" is definded as a percentage change above the given threshold. + + Parameters + ---------- + tb: TaxBrain object + threshold: Percentage change in an aggregate variable for it to be + considered notable """ notable_list = [] # loop through all of the notable variables and see if there is a year diff --git a/taxbrain/taxbrain.py b/taxbrain/taxbrain.py index 7da5430..6bce5a3 100644 --- a/taxbrain/taxbrain.py +++ b/taxbrain/taxbrain.py @@ -89,6 +89,8 @@ def __init__(self, start_year: int, end_year: int = LAST_BUDGET_YEAR, base_policy = self._process_user_mods(base_policy, None) self.params["base_policy"] = base_policy + self.has_run = False + def run(self, varlist: list = DEFAULT_VARIABLES): """ Run the calculators. TaxBrain will determine whether to do a static or @@ -113,6 +115,7 @@ def run(self, varlist: list = DEFAULT_VARIABLES): if self.verbose: print("Running static simulations") self._static_run(varlist, base_calc, reform_calc) + setattr(self, "has_run", True) del base_calc, reform_calc diff --git a/taxbrain/tests/expected_weighted_table.csv b/taxbrain/tests/expected_weighted_table.csv index 4a1d53b..a7269ab 100644 --- a/taxbrain/tests/expected_weighted_table.csv +++ b/taxbrain/tests/expected_weighted_table.csv @@ -1,4 +1,4 @@ ,2018,2019 -Base,2467387151560.0347,2612542923769.0073 -Reform,2467387151560.0347,2640916516377.9233 -Difference,0.0,28373592608.916016 +Base,2469977377368.852,2615396291735.824 +Reform,2469977377368.852,2643836717370.0815 +Difference,0.0,28440425634.257324 diff --git a/taxbrain/tests/test_report.py b/taxbrain/tests/test_report.py new file mode 100644 index 0000000..f2e846e --- /dev/null +++ b/taxbrain/tests/test_report.py @@ -0,0 +1,26 @@ +import shutil +from pathlib import Path +from taxbrain import report + + +def test_report(tb_static): + """ + Ensure that all report files are created + """ + outdir = "testreform" + name = "Test Report" + report( + tb_static, name=name, outdir=outdir + ) + dir_path = Path(outdir) + assert dir_path.exists() + assert Path(dir_path, "Test-Report.md").exists() + assert Path(dir_path, "Test-Report.pdf").exists() + assert Path(dir_path, "Test-Report.html").exists() + diff_png = Path(dir_path, "difference_graph.png") + diff_svg = Path(dir_path, "difference_graph.svg") + assert diff_png.exists() or diff_svg.exists() + dist_png = Path(dir_path, "dist_graph.png") + dist_svg = Path(dir_path, "dist_graph.svg") + assert dist_png.exists() or dist_svg.exists() + shutil.rmtree(dir_path) From ce79a5c4259387af447f81cdbcb0fe7c90036a59 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Tue, 23 Jul 2019 11:26:59 -0400 Subject: [PATCH 4/5] modify environment, author --- environment.yml | 4 ++-- taxbrain/report.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/environment.yml b/environment.yml index d74bd84..2e3d67a 100644 --- a/environment.yml +++ b/environment.yml @@ -4,8 +4,8 @@ channels: - conda-forge dependencies: - python>=3.6.5 -- taxcalc>=2.2.0 -- behresp>=0.8.0 +- taxcalc>=2.4.2 +- behresp>=0.9.0 - pandas>=0.23 - numpy>=1.13 - pytest diff --git a/taxbrain/report.py b/taxbrain/report.py index a5f62d2..d34ba06 100644 --- a/taxbrain/report.py +++ b/taxbrain/report.py @@ -15,7 +15,7 @@ def report(tb, name=None, change_threshold=0.05, description=None, - outdir=None, author="", css=None, + outdir=None, author=None, css=None, verbose=False): """ Create a PDF report based on TaxBrain results @@ -68,6 +68,8 @@ def export_plot(plot, graph): name = f"Policy Report-{date()}" if not outdir: outdir = "-".join(name) + if author: + author = f"Report Prepared by {author.title()}" # create directory to hold report contents output_path = Path(outdir) if not output_path.exists(): From 687163d33f19d01b99e98cc2dad5c70f779969a9 Mon Sep 17 00:00:00 2001 From: andersonfrailey Date: Wed, 24 Jul 2019 09:05:22 -0400 Subject: [PATCH 5/5] update manifest --- MANIFEST.in | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index ff5c492..c338f59 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,3 @@ -include taxbrain/report_text/report_template.md \ No newline at end of file +include taxbrain/report_files/report_template.md +include taxbrain/report_files/report_style.css +include taxbrain/report_files/taxbrain.png \ No newline at end of file