Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.16.2] - 2026-06-11 18:00:00

### Added

- Adds `output_tables.model_fit_table` function that generates a table comparing model output to data for a set of target moments, and adds a test of this function in `test_output_tables.py`. The function takes as input a list of target moment descriptions, the TPI output dictionary, and the model parameters object, and returns a pandas DataFrame with the target moment descriptions, the model values for those moments, and the data values for those moments (where applicable). The function currently supports a set of macroeconomic moments (interest rate, capital share of output, labor share of output), inequality moments (Gini coefficient for before-tax income and after-tax income), and demographic moments (fraction of population 65+ and population growth rate). See PR [#1138](https://github.com/PSLmodels/OG-Core/pull/1138).
- Validates `beta_annual` and `chi_b` against `J`. ([PR #1149](https://github.com/PSLmodels/OG-Core/pull/1149))

### Bug Fixes
- Fixes issue with reading UN data on Pandas >= 3.0 and UN token string. ([PR #1151](https://github.com/PSLmodels/OG-Core/pull/1151))
- Fixes math notation for plot labels. ([PR #1148](https://github.com/PSLmodels/OG-Core/pull/1148))
- Fixes reshaping issues with `J=1` parameterization. ([PR #1145](https://github.com/PSLmodels/OG-Core/pull/1145))

### Bug Fix

- Fixed math notion for tilde variables in plot labels in `output_plots.py` to be consistent with the documentation and the code. See PR [#1148](https://github.com/PSLmodels/OG-Core/pull/1148).

## [0.16.1] - 2026-06-04 12:00:00

### Added
Expand Down Expand Up @@ -587,6 +603,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Any earlier versions of OG-USA can be found in the [`OG-Core`](https://github.com/PSLmodels/OG-Core) repository [release history](https://github.com/PSLmodels/OG-Core/releases) from [v.0.6.4](https://github.com/PSLmodels/OG-Core/releases/tag/v0.6.4) (Jul. 20, 2021) or earlier.


[0.16.2]: https://github.com/PSLmodels/OG-Core/compare/v0.16.1...v0.16.2
[0.16.1]: https://github.com/PSLmodels/OG-Core/compare/v0.16.0...v0.16.1
[0.16.0]: https://github.com/PSLmodels/OG-Core/compare/v0.15.13...v0.16.0
[0.15.13]: https://github.com/PSLmodels/OG-Core/compare/v0.15.12...v0.15.13
[0.15.12]: https://github.com/PSLmodels/OG-Core/compare/v0.15.11...v0.15.12
Expand Down
2 changes: 1 addition & 1 deletion ogcore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
from ogcore.txfunc import * # noqa: F403
from ogcore.utils import * # noqa: F403

__version__ = "0.16.1"
__version__ = "0.16.2"
6 changes: 6 additions & 0 deletions ogcore/output_plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,12 @@ def ss_profiles(
).sum(axis=1)
plt.plot(age_vec, reform_var, label="Reform", linestyle="--")
if plot_data is not None:
if var != "n":
# If not labor, normalize so data and model match in
# first period
plot_data_arr = np.asarray(plot_data)
if plot_data_arr[0] != 0:
plot_data = plot_data_arr / plot_data_arr[0] * base_var[0]
plt.plot(
age_vec, plot_data, linewidth=2.0, label="Data", linestyle=":"
)
Expand Down
188 changes: 188 additions & 0 deletions ogcore/output_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,3 +987,191 @@ def dynamic_revenue_decomposition(
table = save_return_table(table_df, table_format, path)

return table


def model_fit_table(
targets_dict,
params,
tpi_output,
t=0,
table_format=None,
path=None,
):
Comment thread
jdebacker marked this conversation as resolved.
Comment thread
jdebacker marked this conversation as resolved.
"""
Creates a table summarizing the model fit.

Args:
targets_dict (dict): maps each parameter name to a one-item
dict ``{target_description: data_value}``, e.g.::

{
'Gini coefficient of wealth': 0.82,
'Investment rate (I/K)': 0.07,
'Gini coefficient of income': 0.55,
}

params (OG-Core Specifications class): model parameters object
tpi_output (dict): output dictionary returned by ``TPI.run_TPI``
t (int): period index used for model moment calculations.
Defaults to ``0`` (first period of the transition path).
Pass ``-1`` to use the last period, which approximates
steady-state values.
table_format (string): format to return table in: ``'csv'``,
``'tex'``, ``'excel'``, ``'json'``; if ``None`` a
DataFrame is returned
path (string): path to save table to

Returns:
table (various): table as a DataFrame, formatted string, or
``None`` if saved to disk

"""
# Ordered groups and the moment descriptions belonging to each
MOMENT_GROUPS = [
(
"Macroeconomic moments",
[
r"Investment rate $(I/K)$",
r"Capital-Output ratio $(K/Y)$",
r"Consumption-Output ratio $(C/Y)$",
r"Savings rate $(B/Y)$",
r"Interest rate $(r)$",
r"Capital share of output",
r"Labor share of output",
],
),
(
"Fiscal moments",
[
r"Revenue to GDP ratio $(T/Y)$",
r"Gov't consumption to GDP ratio $(G/Y)$",
r"Pension outlays to GDP ratio $(Pension/Y)$",
r"Infrastructure spending to GDP ratio $(I_g/Y)$",
r"Debt to GDP ratio $(D/Y)$",
],
),
(
"Distributional moments",
[
"Gini coefficient, wealth",
"Gini coefficient, income",
"Gini coefficient, after-tax income",
],
),
(
"Demographic moments",
[
r"Fraction 65+",
r"Pop growth rate",
],
),
]

# Compute model moments for all entries in targets_dict
computed = {}
for moment, data_val in targets_dict.items():
target_desc = moment

# Macroeconomic moments
if target_desc == r"Investment rate $(I/K)$":
model_val = tpi_output["I"][t] / tpi_output["K"][t]
elif target_desc == r"Capital-Output ratio $(K/Y)$":
model_val = tpi_output["K"][t] / tpi_output["Y"][t]
elif target_desc == r"Consumption-Output ratio $(C/Y)$":
model_val = tpi_output["C"][t] / tpi_output["Y"][t]
elif target_desc == r"Savings rate $(B/Y)$":
model_val = tpi_output["B"][t] / tpi_output["Y"][t]
elif target_desc == r"Interest rate $(r)$":
model_val = tpi_output["r"][t]
elif target_desc == r"Capital share of output":
model_val = (
1
- tpi_output["w"][t] * tpi_output["L"][t] / tpi_output["Y"][t]
)
elif target_desc == r"Labor share of output":
model_val = (
tpi_output["w"][t] * tpi_output["L"][t] / tpi_output["Y"][t]
)
# Fiscal moments
elif target_desc == r"Revenue to GDP ratio $(T/Y)$":
model_val = tpi_output["total_tax_revenue"][t] / tpi_output["Y"][t]
elif target_desc == r"Gov't consumption to GDP ratio $(G/Y)$":
model_val = tpi_output["G"][t] / tpi_output["Y"][t]
elif target_desc == r"Pension outlays to GDP ratio $(Pension/Y)$":
model_val = (
tpi_output["agg_pension_outlays"][t] / tpi_output["Y"][t]
)
elif target_desc == r"Infrastructure spending to GDP ratio $(I_g/Y)$":
model_val = tpi_output["I_g"][t] / tpi_output["Y"][t]
elif target_desc == r"Debt to GDP ratio $(D/Y)$":
model_val = tpi_output["D"][t] / tpi_output["Y"][t]
# Distributional moments
elif target_desc == "Gini coefficient, wealth":
dist = tpi_output["b_sp1"][t]
pop_weights = params.omega[t]
pop_weights = pop_weights / pop_weights.sum()
ineq = Inequality(
dist, pop_weights, params.lambdas, params.S, params.J
)
model_val = ineq.gini()
elif target_desc == "Gini coefficient, income":
dist = tpi_output["before_tax_income"][t]
pop_weights = params.omega[t]
pop_weights = pop_weights / pop_weights.sum()
ineq = Inequality(
dist, pop_weights, params.lambdas, params.S, params.J
)
model_val = ineq.gini()
elif target_desc == "Gini coefficient, after-tax income":
dist = (
tpi_output["before_tax_income"][t]
- tpi_output["hh_net_taxes"][t]
)
pop_weights = params.omega[t]
pop_weights = pop_weights / pop_weights.sum()
ineq = Inequality(
dist, pop_weights, params.lambdas, params.S, params.J
)
model_val = ineq.gini()
# Demographic moments
elif target_desc == r"Fraction 65+":
idx_65 = max(0, 65 - params.starting_age)
omega_t = params.omega[t]
model_val = omega_t[idx_65:].sum() / omega_t.sum()
elif target_desc == r"Pop growth rate":
model_val = params.g_n[t]
else:
model_val = np.nan

computed[target_desc] = (data_val, model_val)

# Build the grouped table; skip any group with no matching moments
all_grouped = {m for _, moments in MOMENT_GROUPS for m in moments}
table_dict = {"Moment": [], "Data": [], "Model": []}

for group_name, group_moments in MOMENT_GROUPS:
group_entries = [m for m in group_moments if m in computed]
if not group_entries:
continue
# Group header row (no data values)
table_dict["Moment"].append(group_name)
table_dict["Data"].append(np.nan)
table_dict["Model"].append(np.nan)
# Indented moment rows
for m in group_entries:
data_val, model_val = computed[m]
table_dict["Moment"].append(f" {m}")
table_dict["Data"].append(data_val)
table_dict["Model"].append(model_val)

# Append any moments not belonging to a known group
for target_desc, (data_val, model_val) in computed.items():
if target_desc not in all_grouped:
table_dict["Moment"].append(target_desc)
table_dict["Data"].append(data_val)
table_dict["Model"].append(model_val)

table_df = pd.DataFrame.from_dict(table_dict)
table = save_return_table(table_df, table_format, path, precision=4)

return table
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"

[project]
name = "ogcore"
version = "0.16.1"
version = "0.16.2"
authors = [
{name = "Jason DeBacker and Richard W. Evans"},
]
description = "A general equilibrium overlapping generations model for fiscal policy analysis"
readme = "README.md"
license = {text = "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication"}
requires-python = ">=3.11, <3.14"
requires-python = ">=3.12, <3.14"
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
Expand All @@ -20,7 +20,6 @@ classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
Expand Down Expand Up @@ -109,3 +108,8 @@ markers = [
"real: marks tests using real OG-Core tax function code",
"platform: marks tests for platform-specific optimization",
]

[dependency-groups]
dev = [
"ipykernel>=7.2.0",
]
41 changes: 41 additions & 0 deletions tests/test_output_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,44 @@ def test_dynamic_revenue_decomposition(include_business_tax, full_break_out):
full_break_out=full_break_out,
)
assert isinstance(df, pd.DataFrame)


@pytest.mark.parametrize(
"targets_dict",
[
# Single macroeconomic moment
{r"Interest rate $(r)$": 0.04},
# Multiple macroeconomic moments
{
r"Investment rate $(I/K)$": 0.07,
r"Capital-Output ratio $(K/Y)$": 3.0,
r"Consumption-Output ratio $(C/Y)$": 0.65,
},
# Fiscal moments
{
r"Revenue to GDP ratio $(T/Y)$": 0.20,
r"Debt to GDP ratio $(D/Y)$": 0.60,
},
# Unrecognized moment key (model value falls back to NaN)
{"Custom unrecognized moment": 0.5},
# Mix of known and unknown moments
{
r"Interest rate $(r)$": 0.04,
"Custom unrecognized moment": 0.5,
},
],
ids=[
"single macro moment",
"multiple macro moments",
"fiscal moments",
"unrecognized moment",
"mixed known and unknown",
],
)
def test_model_fit_table(targets_dict):
df = output_tables.model_fit_table(
targets_dict,
base_params,
base_tpi,
)
assert isinstance(df, pd.DataFrame)
Loading
Loading