Skip to content
Merged
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
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ 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.20.4] - 2026-10-05

### Added in PR #1241
### Added
- Allows the linear payroll tax rate `tau_payroll` to vary across lifetime income groups as well as over time. The parameter is now a `T x J` array (`number_dims` changed from 1 to 2 in `default_parameters.json`), extrapolated like `labor_income_tax_noncompliance_rate` and `income_tax_filer`, so a single value (`[[0.1]]`), one value per income group (`[[0.1, ..., 0.2]]`), or a time path (`[[0.1], [0.12]]`) can be entered. Reform JSON files or scripts that previously set `tau_payroll` as a flat list (e.g., `[0.124]`) must now use the nested form (e.g., `[[0.124]]`).
- Adds a `vary tau_payroll by J` case to `test_FOC_labor` in `test_household.py`, a `tau_payroll` extrapolation test to `test_parameters.py`, and a hand-computed check of `get_payroll_tax_revenue` with group-specific rates in `test_aggregates.py`.
- Adds a `by_J` keyword argument (default `False`) to the demographic plotting functions in `parameter_plots.py` (`plot_imm_rates`, `plot_mort_rates`, `plot_population`, `plot_fert_rates`, `plot_mort_rates_data`, `plot_omega_fixed`, `plot_imm_fixed`, `plot_population_path`) now that the demographic objects are T x S x J. With `by_J=False`, population distributions are summed across income groups and rates are averaged across income groups using the population weights in `omega`, so one line is plotted for the overall population. With `by_J=True`, one line is plotted per income group and labeled with the j value. The array-based functions take an `omega` (or `omega_list`) argument to supply the weights. Each function checks the dimensions of its inputs and continues to accept objects without an income-group dimension (e.g., `T x S` or `S` arrays).

### Updated in PR #1241
### Updated
- `tax.income_tax_liab` and `household.FOC_labor` now select `tau_payroll` by `j` (or across all `J` when `j` is `None`) in the same way as the tax noncompliance rates and filer status.
- `aggregates.get_payroll_tax_revenue` now takes household labor supply `n`, effective labor `e`, and population weights in place of aggregate labor `L`, and computes payroll tax revenue as the population-weighted sum of `tau_payroll[t, j] * w * e * n` across households, which is required when the rate differs across income groups. When `tau_payroll` is zero everywhere, the `frac_tax_payroll` fallback is unchanged.
- `Specifications.compute_default_params` now collapses a previously extrapolated `T x J` parameter (`labor_income_tax_noncompliance_rate`, `capital_income_tax_noncompliance_rate`, `replacement_rate_adjust`, `income_tax_filer`, `wealth_tax_filer`, `tau_payroll`) back to a single column when `J` changes and the values are the same across groups, so that these parameters need not be re-specified whenever `J` is changed via `update_specifications` (the `T x S x J` demographic and ability parameters still must be).
- Fixes the population weighting in `plot_mort_rates`, which multiplied mortality rates by the unnormalized population shares rather than taking a weighted average across income groups.
- Fixes the year indexing in `plot_imm_rates`, `plot_mort_rates_data`, and `plot_population_path`, which indexed years as `start_year - year` rather than `year - start_year`.

Expand Down Expand Up @@ -729,6 +735,7 @@ 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.20.4]: https://github.com/PSLmodels/OG-Core/compare/v0.20.3...v0.20.4
[0.20.3]: https://github.com/PSLmodels/OG-Core/compare/v0.20.2...v0.20.3
[0.20.2]: https://github.com/PSLmodels/OG-Core/compare/v0.20.1...v0.20.2
[0.20.1]: https://github.com/PSLmodels/OG-Core/compare/v0.20.0...v0.20.1
Expand Down
2 changes: 1 addition & 1 deletion docs/book/content/intro/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ _Out-of-Range Action:_ error

#### `tau_payroll`
_Description:_ Linear payroll tax rate. Set value for base year, click '+' to add value for next year. All future years not specified are set to last value entered.
_Notes:_ Set to zero as default since tax functions include income and payroll taxes.
_Notes:_ Set to zero as default since tax functions include income and payroll taxes. Can be specified to vary by type J and over year T.
_Value Type:_ float
_Valid Range:_ min = 0.0 and max = 0.99
_Out-of-Range Action:_ error
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.20.3"
__version__ = "0.20.4"
37 changes: 27 additions & 10 deletions ogcore/aggregates.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ def revenue(
# income vs payroll split can be reported; it must NOT be added into
# the total again (that double-counted payroll revenue -- Issue #1199).
payroll_tax_revenue = get_payroll_tax_revenue(
w, L, iit_payroll_tax_revenue, p, method
w, n, e, pop_weights, iit_payroll_tax_revenue, p, method
)
business_tax_revenue = tax.get_biz_tax(w, Y, L, K, p_m, p, m, method).sum(
-1
Expand Down Expand Up @@ -489,17 +489,21 @@ def revenue(
)


def get_payroll_tax_revenue(w, L, iit_payroll_tax_revenue, p, method):
def get_payroll_tax_revenue(
w, n, e, pop_weights, iit_payroll_tax_revenue, p, method
):
r"""
Calculate aggregate payroll tax revenue.

How payroll tax revenue is computed depends on how the user has
chosen to represent payroll taxes in the model. If payroll taxes
are included directly through the ``tau_payroll`` parameter, then
revenue is the payroll tax rate times aggregate labor income:
revenue is the sum across households of the (type-specific) payroll
tax rate times household labor income:

.. math::
PR_{t} = \tau^{p}_{t}w_{t}L_{t}
PR_{t} = \sum_{s=E}^{E+S}\sum_{j=0}^{J}\omega_{s,j,t}
\tau^{p}_{j,t}w_{t}e_{j,s,t}n_{j,s,t}

Otherwise, payroll taxes are assumed to be embedded in the estimated
income and payroll tax functions (the default), and payroll tax
Expand All @@ -509,7 +513,12 @@ def get_payroll_tax_revenue(w, L, iit_payroll_tax_revenue, p, method):

Args:
w (array_like): the real wage rate
L (array_like): aggregate labor by industry
n (Numpy array): household labor supply (S x J for SS, or
T x S x J for TPI)
e (Numpy array): effective labor units (S x J for SS, or
T x S x J for TPI)
pop_weights (Numpy array): population weights for each
household (S x J for SS, or T x S x J for TPI)
iit_payroll_tax_revenue (array_like): aggregate income and
payroll tax revenue
p (OG-Core Specifications object): model parameters
Expand All @@ -522,14 +531,22 @@ def get_payroll_tax_revenue(w, L, iit_payroll_tax_revenue, p, method):
"""
if np.any(p.tau_payroll != 0):
# Payroll taxes are modeled explicitly via tau_payroll, so
# revenue is the payroll tax rate times aggregate labor income
# (summing labor across industries).
L_total = L.sum(-1)
# revenue is the (type-specific) payroll tax rate times
# household labor income, summed over all households.
if method == "SS":
payroll_tax_revenue = p.tau_payroll[-1] * w * L_total
tau_payroll = p.tau_payroll[-1, :].reshape(1, p.J)
labor_income = w * e * n
payroll_tax_revenue = (
tau_payroll * labor_income * pop_weights
).sum()
else: # TPI
tau_payroll = p.tau_payroll[: p.T, :].reshape(p.T, 1, p.J)
w = w[: p.T].reshape(p.T, 1, 1)
labor_income = w * e[: p.T, :, :] * n[: p.T, :, :]
payroll_tax_revenue = (
p.tau_payroll[: p.T] * w[: p.T] * L_total[: p.T]
(tau_payroll * labor_income * pop_weights[: p.T, :, :])
.sum(1)
.sum(1)
)
else:
# Payroll taxes are embedded in the income and payroll tax
Expand Down
10 changes: 5 additions & 5 deletions ogcore/default_parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -1431,17 +1431,17 @@
"title": "Linear payroll tax rate",
"description": "Linear payroll tax rate. Set value for base year, click '+' to add value for next year. All future years not specified are set to last value entered.",
"short_description": "Payroll tax rate",
"param_notation": "$\\tau^{p}_{t}$",
"param_notation": "$\\tau^{p}_{j,t}$",
"section_1": "Fiscal Policy Parameters",
"section_2": "Taxes",
"notes": "Set to zero as default since tax functions include income and payroll taxes.",
"notes": "Set to zero as default since tax functions include income and payroll taxes. Can be specified to vary by type J and over year T.",
"type": "float",
"number_dims": 1,
"number_dims": 2,
"value": [
{
"value": [
"value": [[
0.0
]
]]
}
],
"validators": {
Expand Down
9 changes: 6 additions & 3 deletions ogcore/household.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,45 +689,48 @@ def FOC_labor(

"""
if method == "SS":
tau_payroll = p.tau_payroll[-1]
tau_c = p.tau_c[-1, :]
elif method == "TPI_scalar": # for 1st donut ring only
tau_payroll = p.tau_payroll[0]
tau_c = p.tau_c[0, :]
else:
length = r.shape[0]
tau_payroll = p.tau_payroll[t : t + length]
tau_c = p.tau_c[t : t + length, :]
if j is not None:
if method == "SS":
tax_noncompliance = p.labor_income_tax_noncompliance_rate[-1, j]
income_tax_filer = p.income_tax_filer[-1, j]
tau_payroll = p.tau_payroll[-1, j]
e = np.squeeze(p.e[-1, :, j])
elif method == "TPI_scalar":
tax_noncompliance = p.labor_income_tax_noncompliance_rate[0, j]
income_tax_filer = p.income_tax_filer[0, j]
tau_payroll = p.tau_payroll[0, j]
e = np.squeeze(p.e[0, -1, j])
else:
tax_noncompliance = p.labor_income_tax_noncompliance_rate[
t : t + length, j
]
income_tax_filer = p.income_tax_filer[t : t + length, j]
tau_payroll = p.tau_payroll[t : t + length, j]
e_long = _get_e_long(p)
e = np.diag(e_long[t : t + p.S, :, j], max(p.S - length, 0))
else:
if method == "SS":
tax_noncompliance = p.labor_income_tax_noncompliance_rate[-1, :]
income_tax_filer = p.income_tax_filer[-1, :]
tau_payroll = p.tau_payroll[-1, :]
e = np.squeeze(p.e[-1, :, :])
elif method == "TPI_scalar":
tax_noncompliance = p.labor_income_tax_noncompliance_rate[0, :]
income_tax_filer = p.income_tax_filer[0, :]
tau_payroll = p.tau_payroll[0, :]
e = np.squeeze(p.e[0, -1, :])
else:
tax_noncompliance = p.labor_income_tax_noncompliance_rate[
t : t + length, :
]
income_tax_filer = p.income_tax_filer[t : t + length, :]
tau_payroll = p.tau_payroll[t : t + length, :]
e_long = _get_e_long(p)
e = np.diag(e_long[t : t + p.S, :, j], max(p.S - length, 0))
if method == "TPI":
Expand Down
16 changes: 14 additions & 2 deletions ogcore/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,6 @@ def compute_default_params(self):
"world_int_rate_annual",
"adjustment_factor_for_cit_receipts",
"tau_bq",
"tau_payroll",
"h_wealth",
"m_wealth",
"p_wealth",
Expand Down Expand Up @@ -233,9 +232,22 @@ def compute_default_params(self):
"replacement_rate_adjust",
"income_tax_filer",
"wealth_tax_filer",
"tau_payroll",
]
for item in tp_param_list3:
param_in = getattr(self, item)
param_in = np.asarray(getattr(self, item))
# If J has changed since this parameter was last extrapolated
# (e.g., via update_specifications), a previously extrapolated
# T+S x J_old array will not match the new J. When the values
# are the same across the old J groups, collapse to one column
# so that it can be re-extrapolated to the new J. Otherwise,
# leave it to extrapolate_array to raise the error.
if (
param_in.ndim == 2
and param_in.shape[1] != self.J
and np.all(param_in == param_in[:, :1])
):
param_in = param_in[:, :1]
param_out = extrapolate_array(
param_in, dims=(self.T + self.S, self.J), item=item
)
Expand Down
39 changes: 22 additions & 17 deletions ogcore/tax.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,9 @@ def income_tax_liab(r, w, b, n, factor, t, j, method, e, etr_params, p):
tax_filer = p.income_tax_filer[t : t + length, j].reshape(
length, 1
)
tau_payroll = p.tau_payroll[t : t + length, j].reshape(
length, 1
)
else:
labor_income_tax_compliance_rate = (
p.labor_income_tax_noncompliance_rate[t : t + length, j]
Expand All @@ -364,6 +367,7 @@ def income_tax_liab(r, w, b, n, factor, t, j, method, e, etr_params, p):
p.capital_income_tax_noncompliance_rate[t : t + length, j]
)
tax_filer = p.income_tax_filer[t : t + length, j]
tau_payroll = p.tau_payroll[t : t + length, j]
elif method == "TPI_scalar":
labor_income_tax_compliance_rate = (
p.labor_income_tax_noncompliance_rate[0, j]
Expand All @@ -372,6 +376,7 @@ def income_tax_liab(r, w, b, n, factor, t, j, method, e, etr_params, p):
p.capital_income_tax_noncompliance_rate[0, j]
)
tax_filer = p.income_tax_filer[0, j]
tau_payroll = p.tau_payroll[0, j]
else:
labor_income_tax_compliance_rate = (
p.labor_income_tax_noncompliance_rate[-1, j]
Expand All @@ -380,6 +385,7 @@ def income_tax_liab(r, w, b, n, factor, t, j, method, e, etr_params, p):
p.capital_income_tax_noncompliance_rate[-1, j]
)
tax_filer = p.income_tax_filer[-1, j]
tau_payroll = p.tau_payroll[-1, j]
else:
if method == "TPI":
r = utils.to_timepath_shape(r)
Expand All @@ -398,6 +404,18 @@ def income_tax_liab(r, w, b, n, factor, t, j, method, e, etr_params, p):
tax_filer = p.income_tax_filer[t : t + length, :].reshape(
length, 1, p.J
)
tau_payroll = p.tau_payroll[t : t + length, :].reshape(
length, 1, p.J
)
elif method == "TPI_scalar":
labor_income_tax_compliance_rate = (
p.labor_income_tax_noncompliance_rate[0, :]
)
capital_income_tax_compliance_rate = (
p.capital_income_tax_noncompliance_rate[0, :]
)
tax_filer = p.income_tax_filer[0, :]
tau_payroll = p.tau_payroll[0, :]
else:
labor_income_tax_compliance_rate = (
p.labor_income_tax_noncompliance_rate[-1, :]
Expand All @@ -406,6 +424,7 @@ def income_tax_liab(r, w, b, n, factor, t, j, method, e, etr_params, p):
p.capital_income_tax_noncompliance_rate[-1, :]
)
tax_filer = p.income_tax_filer[-1, :]
tau_payroll = p.tau_payroll[-1, :]
income = r * b + w * e * n
labor_income = w * e * n

Expand All @@ -426,23 +445,9 @@ def income_tax_liab(r, w, b, n, factor, t, j, method, e, etr_params, p):
* income
)

if method == "SS":
T_P = p.tau_payroll[-1] * labor_income
elif method == "TPI":
length = w.shape[0]
if len(b.shape) == 1:
T_P = p.tau_payroll[t : t + length] * labor_income
elif len(b.shape) == 2:
T_P = (
p.tau_payroll[t : t + length].reshape(length, 1) * labor_income
)
else:
T_P = (
p.tau_payroll[t : t + length].reshape(length, 1, 1)
* labor_income
)
elif method == "TPI_scalar":
T_P = p.tau_payroll[0] * labor_income
# Linear payroll tax, with a rate that may vary over time and by
# lifetime income group j (tau_payroll is T+S x J)
T_P = tau_payroll * labor_income

income_payroll_tax_liab = T_I + T_P

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ogcore"
version = "0.20.3"
version = "0.20.4"
authors = [
{name = "Jason DeBacker and Richard W. Evans"},
]
Expand Down
Loading
Loading