Skip to content

Commit b4f5dbb

Browse files
authored
Merge pull request #1138 from jdebacker/calib_tables
Add `model_fit_table()` function to summarize endogenous calibration targets
2 parents f432998 + 1d8ad70 commit b4f5dbb

7 files changed

Lines changed: 271 additions & 311 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.16.2] - 2026-06-15 12:00:00
9+
10+
### Added
11+
12+
- 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).
13+
- Validates `beta_annual` and `chi_b` against `J`. ([PR #1149](https://github.com/PSLmodels/OG-Core/pull/1149))
14+
15+
### Bug Fixes
16+
- Fixes issue with reading UN data on Pandas >= 3.0 and UN token string. ([PR #1151](https://github.com/PSLmodels/OG-Core/pull/1151))
17+
- Fixes math notation for plot labels. ([PR #1148](https://github.com/PSLmodels/OG-Core/pull/1148))
18+
- Fixes reshaping issues with `J=1` parameterization. ([PR #1145](https://github.com/PSLmodels/OG-Core/pull/1145))
19+
20+
### Bug Fix
21+
22+
- 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).
23+
824
## [0.16.1] - 2026-06-04 12:00:00
925

1026
### Added
@@ -587,6 +603,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
587603
- 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.
588604

589605

606+
[0.16.2]: https://github.com/PSLmodels/OG-Core/compare/v0.16.1...v0.16.2
607+
[0.16.1]: https://github.com/PSLmodels/OG-Core/compare/v0.16.0...v0.16.1
590608
[0.16.0]: https://github.com/PSLmodels/OG-Core/compare/v0.15.13...v0.16.0
591609
[0.15.13]: https://github.com/PSLmodels/OG-Core/compare/v0.15.12...v0.15.13
592610
[0.15.12]: https://github.com/PSLmodels/OG-Core/compare/v0.15.11...v0.15.12

ogcore/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@
2121
from ogcore.txfunc import * # noqa: F403
2222
from ogcore.utils import * # noqa: F403
2323

24-
__version__ = "0.16.1"
24+
__version__ = "0.16.2"

ogcore/output_plots.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,12 @@ def ss_profiles(
835835
).sum(axis=1)
836836
plt.plot(age_vec, reform_var, label="Reform", linestyle="--")
837837
if plot_data is not None:
838+
if var != "n":
839+
# If not labor, normalize so data and model match in
840+
# first period
841+
plot_data_arr = np.asarray(plot_data)
842+
if plot_data_arr[0] != 0:
843+
plot_data = plot_data_arr / plot_data_arr[0] * base_var[0]
838844
plt.plot(
839845
age_vec, plot_data, linewidth=2.0, label="Data", linestyle=":"
840846
)

ogcore/output_tables.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,3 +987,191 @@ def dynamic_revenue_decomposition(
987987
table = save_return_table(table_df, table_format, path)
988988

989989
return table
990+
991+
992+
def model_fit_table(
993+
targets_dict,
994+
params,
995+
tpi_output,
996+
t=0,
997+
table_format=None,
998+
path=None,
999+
):
1000+
"""
1001+
Creates a table summarizing the model fit.
1002+
1003+
Args:
1004+
targets_dict (dict): maps each parameter name to a one-item
1005+
dict ``{target_description: data_value}``, e.g.::
1006+
1007+
{
1008+
'Gini coefficient of wealth': 0.82,
1009+
'Investment rate (I/K)': 0.07,
1010+
'Gini coefficient of income': 0.55,
1011+
}
1012+
1013+
params (OG-Core Specifications class): model parameters object
1014+
tpi_output (dict): output dictionary returned by ``TPI.run_TPI``
1015+
t (int): period index used for model moment calculations.
1016+
Defaults to ``0`` (first period of the transition path).
1017+
Pass ``-1`` to use the last period, which approximates
1018+
steady-state values.
1019+
table_format (string): format to return table in: ``'csv'``,
1020+
``'tex'``, ``'excel'``, ``'json'``; if ``None`` a
1021+
DataFrame is returned
1022+
path (string): path to save table to
1023+
1024+
Returns:
1025+
table (various): table as a DataFrame, formatted string, or
1026+
``None`` if saved to disk
1027+
1028+
"""
1029+
# Ordered groups and the moment descriptions belonging to each
1030+
MOMENT_GROUPS = [
1031+
(
1032+
"Macroeconomic moments",
1033+
[
1034+
r"Investment rate $(I/K)$",
1035+
r"Capital-Output ratio $(K/Y)$",
1036+
r"Consumption-Output ratio $(C/Y)$",
1037+
r"Savings rate $(B/Y)$",
1038+
r"Interest rate $(r)$",
1039+
r"Capital share of output",
1040+
r"Labor share of output",
1041+
],
1042+
),
1043+
(
1044+
"Fiscal moments",
1045+
[
1046+
r"Revenue to GDP ratio $(T/Y)$",
1047+
r"Gov't consumption to GDP ratio $(G/Y)$",
1048+
r"Pension outlays to GDP ratio $(Pension/Y)$",
1049+
r"Infrastructure spending to GDP ratio $(I_g/Y)$",
1050+
r"Debt to GDP ratio $(D/Y)$",
1051+
],
1052+
),
1053+
(
1054+
"Distributional moments",
1055+
[
1056+
"Gini coefficient, wealth",
1057+
"Gini coefficient, income",
1058+
"Gini coefficient, after-tax income",
1059+
],
1060+
),
1061+
(
1062+
"Demographic moments",
1063+
[
1064+
r"Fraction 65+",
1065+
r"Pop growth rate",
1066+
],
1067+
),
1068+
]
1069+
1070+
# Compute model moments for all entries in targets_dict
1071+
computed = {}
1072+
for moment, data_val in targets_dict.items():
1073+
target_desc = moment
1074+
1075+
# Macroeconomic moments
1076+
if target_desc == r"Investment rate $(I/K)$":
1077+
model_val = tpi_output["I"][t] / tpi_output["K"][t]
1078+
elif target_desc == r"Capital-Output ratio $(K/Y)$":
1079+
model_val = tpi_output["K"][t] / tpi_output["Y"][t]
1080+
elif target_desc == r"Consumption-Output ratio $(C/Y)$":
1081+
model_val = tpi_output["C"][t] / tpi_output["Y"][t]
1082+
elif target_desc == r"Savings rate $(B/Y)$":
1083+
model_val = tpi_output["B"][t] / tpi_output["Y"][t]
1084+
elif target_desc == r"Interest rate $(r)$":
1085+
model_val = tpi_output["r"][t]
1086+
elif target_desc == r"Capital share of output":
1087+
model_val = (
1088+
1
1089+
- tpi_output["w"][t] * tpi_output["L"][t] / tpi_output["Y"][t]
1090+
)
1091+
elif target_desc == r"Labor share of output":
1092+
model_val = (
1093+
tpi_output["w"][t] * tpi_output["L"][t] / tpi_output["Y"][t]
1094+
)
1095+
# Fiscal moments
1096+
elif target_desc == r"Revenue to GDP ratio $(T/Y)$":
1097+
model_val = tpi_output["total_tax_revenue"][t] / tpi_output["Y"][t]
1098+
elif target_desc == r"Gov't consumption to GDP ratio $(G/Y)$":
1099+
model_val = tpi_output["G"][t] / tpi_output["Y"][t]
1100+
elif target_desc == r"Pension outlays to GDP ratio $(Pension/Y)$":
1101+
model_val = (
1102+
tpi_output["agg_pension_outlays"][t] / tpi_output["Y"][t]
1103+
)
1104+
elif target_desc == r"Infrastructure spending to GDP ratio $(I_g/Y)$":
1105+
model_val = tpi_output["I_g"][t] / tpi_output["Y"][t]
1106+
elif target_desc == r"Debt to GDP ratio $(D/Y)$":
1107+
model_val = tpi_output["D"][t] / tpi_output["Y"][t]
1108+
# Distributional moments
1109+
elif target_desc == "Gini coefficient, wealth":
1110+
dist = tpi_output["b_sp1"][t]
1111+
pop_weights = params.omega[t]
1112+
pop_weights = pop_weights / pop_weights.sum()
1113+
ineq = Inequality(
1114+
dist, pop_weights, params.lambdas, params.S, params.J
1115+
)
1116+
model_val = ineq.gini()
1117+
elif target_desc == "Gini coefficient, income":
1118+
dist = tpi_output["before_tax_income"][t]
1119+
pop_weights = params.omega[t]
1120+
pop_weights = pop_weights / pop_weights.sum()
1121+
ineq = Inequality(
1122+
dist, pop_weights, params.lambdas, params.S, params.J
1123+
)
1124+
model_val = ineq.gini()
1125+
elif target_desc == "Gini coefficient, after-tax income":
1126+
dist = (
1127+
tpi_output["before_tax_income"][t]
1128+
- tpi_output["hh_net_taxes"][t]
1129+
)
1130+
pop_weights = params.omega[t]
1131+
pop_weights = pop_weights / pop_weights.sum()
1132+
ineq = Inequality(
1133+
dist, pop_weights, params.lambdas, params.S, params.J
1134+
)
1135+
model_val = ineq.gini()
1136+
# Demographic moments
1137+
elif target_desc == r"Fraction 65+":
1138+
idx_65 = max(0, 65 - params.starting_age)
1139+
omega_t = params.omega[t]
1140+
model_val = omega_t[idx_65:].sum() / omega_t.sum()
1141+
elif target_desc == r"Pop growth rate":
1142+
model_val = params.g_n[t]
1143+
else:
1144+
model_val = np.nan
1145+
1146+
computed[target_desc] = (data_val, model_val)
1147+
1148+
# Build the grouped table; skip any group with no matching moments
1149+
all_grouped = {m for _, moments in MOMENT_GROUPS for m in moments}
1150+
table_dict = {"Moment": [], "Data": [], "Model": []}
1151+
1152+
for group_name, group_moments in MOMENT_GROUPS:
1153+
group_entries = [m for m in group_moments if m in computed]
1154+
if not group_entries:
1155+
continue
1156+
# Group header row (no data values)
1157+
table_dict["Moment"].append(group_name)
1158+
table_dict["Data"].append(np.nan)
1159+
table_dict["Model"].append(np.nan)
1160+
# Indented moment rows
1161+
for m in group_entries:
1162+
data_val, model_val = computed[m]
1163+
table_dict["Moment"].append(f" {m}")
1164+
table_dict["Data"].append(data_val)
1165+
table_dict["Model"].append(model_val)
1166+
1167+
# Append any moments not belonging to a known group
1168+
for target_desc, (data_val, model_val) in computed.items():
1169+
if target_desc not in all_grouped:
1170+
table_dict["Moment"].append(target_desc)
1171+
table_dict["Data"].append(data_val)
1172+
table_dict["Model"].append(model_val)
1173+
1174+
table_df = pd.DataFrame.from_dict(table_dict)
1175+
table = save_return_table(table_df, table_format, path, precision=4)
1176+
1177+
return table

pyproject.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ogcore"
7-
version = "0.16.1"
7+
version = "0.16.2"
88
authors = [
99
{name = "Jason DeBacker and Richard W. Evans"},
1010
]
1111
description = "A general equilibrium overlapping generations model for fiscal policy analysis"
1212
readme = "README.md"
1313
license = {text = "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication"}
14-
requires-python = ">=3.11, <3.14"
14+
requires-python = ">=3.12, <3.14"
1515
classifiers = [
1616
"Development Status :: 2 - Pre-Alpha",
1717
"Intended Audience :: Developers",
@@ -20,7 +20,6 @@ classifiers = [
2020
"Operating System :: OS Independent",
2121
"Programming Language :: Python",
2222
"Programming Language :: Python :: 3",
23-
"Programming Language :: Python :: 3.11",
2423
"Programming Language :: Python :: 3.12",
2524
"Programming Language :: Python :: 3.13",
2625
"Topic :: Software Development :: Libraries :: Python Modules",
@@ -109,3 +108,8 @@ markers = [
109108
"real: marks tests using real OG-Core tax function code",
110109
"platform: marks tests for platform-specific optimization",
111110
]
111+
112+
[dependency-groups]
113+
dev = [
114+
"ipykernel>=7.2.0",
115+
]

tests/test_output_tables.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,44 @@ def test_dynamic_revenue_decomposition(include_business_tax, full_break_out):
197197
full_break_out=full_break_out,
198198
)
199199
assert isinstance(df, pd.DataFrame)
200+
201+
202+
@pytest.mark.parametrize(
203+
"targets_dict",
204+
[
205+
# Single macroeconomic moment
206+
{r"Interest rate $(r)$": 0.04},
207+
# Multiple macroeconomic moments
208+
{
209+
r"Investment rate $(I/K)$": 0.07,
210+
r"Capital-Output ratio $(K/Y)$": 3.0,
211+
r"Consumption-Output ratio $(C/Y)$": 0.65,
212+
},
213+
# Fiscal moments
214+
{
215+
r"Revenue to GDP ratio $(T/Y)$": 0.20,
216+
r"Debt to GDP ratio $(D/Y)$": 0.60,
217+
},
218+
# Unrecognized moment key (model value falls back to NaN)
219+
{"Custom unrecognized moment": 0.5},
220+
# Mix of known and unknown moments
221+
{
222+
r"Interest rate $(r)$": 0.04,
223+
"Custom unrecognized moment": 0.5,
224+
},
225+
],
226+
ids=[
227+
"single macro moment",
228+
"multiple macro moments",
229+
"fiscal moments",
230+
"unrecognized moment",
231+
"mixed known and unknown",
232+
],
233+
)
234+
def test_model_fit_table(targets_dict):
235+
df = output_tables.model_fit_table(
236+
targets_dict,
237+
base_params,
238+
base_tpi,
239+
)
240+
assert isinstance(df, pd.DataFrame)

0 commit comments

Comments
 (0)