From 07b2e79e0b15f9a1ddb1df95f3b0727364afeb39 Mon Sep 17 00:00:00 2001 From: arihantlodha-cmd Date: Thu, 23 Jul 2026 19:43:44 +0900 Subject: [PATCH 1/2] Add get_payroll_tax_revenue function for correct payroll revenue Closes #1023. Previously payroll tax revenue was always computed as frac_tax_payroll * iit_payroll_tax_revenue, which is incorrect when a user models payroll taxes explicitly via tau_payroll (and excludes them from the tax functions). Adds get_payroll_tax_revenue, which uses tau_payroll * w * L when tau_payroll is nonzero and falls back to the existing fraction-of- combined-revenue calculation otherwise. Identical results when tau_payroll is zero (the default), so default runs are unchanged. Adds test_get_payroll_tax_revenue covering both branches for SS and TPI. --- ogcore/aggregates.py | 64 +++++++++++++++++++++++++++++++++++++--- tests/test_aggregates.py | 39 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/ogcore/aggregates.py b/ogcore/aggregates.py index 02b830f4a..acd9a2f3e 100644 --- a/ogcore/aggregates.py +++ b/ogcore/aggregates.py @@ -399,7 +399,6 @@ def revenue( cons_tax_revenue = ( tax.cons_tax_liab(c, p_i, p, method) * pop_weights ).sum() - payroll_tax_revenue = p.frac_tax_payroll[-1] * iit_payroll_tax_revenue elif method == "TPI": p_i = ( np.tile(p.io_matrix.reshape(1, p.I, p.M), (p.T, 1, 1)) @@ -416,9 +415,9 @@ def revenue( cons_tax_revenue = ( (tax.cons_tax_liab(c, p_i, p, method) * pop_weights).sum(1).sum(1) ) - payroll_tax_revenue = ( - p.frac_tax_payroll[: p.T] * iit_payroll_tax_revenue - ) + payroll_tax_revenue = get_payroll_tax_revenue( + w, L, iit_payroll_tax_revenue, p, method + ) business_tax_revenue = tax.get_biz_tax(w, Y, L, K, p_m, p, m, method).sum( -1 ) @@ -446,6 +445,63 @@ def revenue( ) +def get_payroll_tax_revenue(w, L, 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: + + .. math:: + PR_{t} = \tau^{p}_{t}w_{t}L_{t} + + Otherwise, payroll taxes are assumed to be embedded in the estimated + income and payroll tax functions (the default), and payroll tax + revenue is separated out as a fraction ``frac_tax_payroll`` of the + combined income and payroll tax revenue. These two calculations are + identical when ``tau_payroll`` is zero. + + Args: + w (array_like): the real wage rate + L (array_like): aggregate labor by industry + iit_payroll_tax_revenue (array_like): aggregate income and + payroll tax revenue + p (OG-Core Specifications object): model parameters + method (str): adjusts calculation dimensions based on 'SS' or + 'TPI' + + Returns: + payroll_tax_revenue (array_like): aggregate payroll tax revenue + + """ + 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) + if method == "SS": + payroll_tax_revenue = p.tau_payroll[-1] * w * L_total + else: # TPI + payroll_tax_revenue = ( + p.tau_payroll[: p.T] * w[: p.T] * L_total[: p.T] + ) + else: + # Payroll taxes are embedded in the income and payroll tax + # functions, so revenue is a fraction of the combined revenue. + if method == "SS": + payroll_tax_revenue = ( + p.frac_tax_payroll[-1] * iit_payroll_tax_revenue + ) + else: # TPI + payroll_tax_revenue = ( + p.frac_tax_payroll[: p.T] * iit_payroll_tax_revenue + ) + + return payroll_tax_revenue + + def get_r_p(r, r_gov, p_m, K_vec, K_g, D, MPKg_vec, p, method): r""" Compute the interest rate on the household's portfolio of assets, diff --git a/tests/test_aggregates.py b/tests/test_aggregates.py index 4556018d5..82b5695ad 100644 --- a/tests/test_aggregates.py +++ b/tests/test_aggregates.py @@ -1744,6 +1744,45 @@ def test_revenue( assert np.allclose(revenue, expected) +def test_get_payroll_tax_revenue(): + """ + Test of the aggregates.get_payroll_tax_revenue function. + + Checks both ways of representing payroll taxes: embedded in the + income and payroll tax functions (tau_payroll == 0, the default) and + modeled explicitly via the tau_payroll parameter. + """ + p = Specifications() + p.T = 3 + iit_payroll_ss = 10.0 + iit_payroll_tpi = np.array([10.0, 11.0, 12.0]) + w_ss = 1.2 + L_ss = np.array([2.0, 3.0]) # labor by industry + w_tpi = np.array([1.0, 1.1, 1.2]) + L_tpi = np.array([[1.0, 2.0], [1.5, 2.5], [2.0, 3.0]]) # (T, M) + + # Payroll taxes embedded in the tax functions (tau_payroll == 0) + p.tau_payroll = np.zeros(p.T) + p.frac_tax_payroll = np.array([0.5, 0.5, 0.5]) + pr_ss = aggr.get_payroll_tax_revenue(w_ss, L_ss, iit_payroll_ss, p, "SS") + assert np.allclose(pr_ss, 0.5 * iit_payroll_ss) + pr_tpi = aggr.get_payroll_tax_revenue( + w_tpi, L_tpi, iit_payroll_tpi, p, "TPI" + ) + assert np.allclose(pr_tpi, 0.5 * iit_payroll_tpi) + + # Payroll taxes modeled explicitly via tau_payroll + p.tau_payroll = np.array([0.1, 0.2, 0.3]) + pr_ss = aggr.get_payroll_tax_revenue(w_ss, L_ss, iit_payroll_ss, p, "SS") + assert np.allclose(pr_ss, 0.3 * w_ss * L_ss.sum()) + pr_tpi = aggr.get_payroll_tax_revenue( + w_tpi, L_tpi, iit_payroll_tpi, p, "TPI" + ) + assert np.allclose( + pr_tpi, np.array([0.1, 0.2, 0.3]) * w_tpi * L_tpi.sum(-1) + ) + + test_data = [ ( 0.04, From 5df0fc9eab95f92ad43290a85c73cc9ed21acf0f Mon Sep 17 00:00:00 2001 From: arihantlodha-cmd Date: Tue, 4 Aug 2026 20:03:59 +0900 Subject: [PATCH 2/2] Include explicit payroll tax revenue in total when tau_payroll != 0 Per review feedback (#1184): when payroll taxes are modeled explicitly via tau_payroll (and excluded from the income+payroll tax functions), iit_payroll_tax_revenue holds only income tax, so payroll_tax_revenue is now added back into it before computing total_tax_revenue and iit_revenue. Regenerated the test_revenue golden values, whose params use tau_payroll=0.5: each new total equals the previous total plus payroll_tax_revenue (verified by construction). No change to default (tau_payroll == 0) runs. --- ogcore/aggregates.py | 6 ++ tests/test_aggregates.py | 184 +++++++++++++++++++-------------------- 2 files changed, 98 insertions(+), 92 deletions(-) diff --git a/ogcore/aggregates.py b/ogcore/aggregates.py index acd9a2f3e..9626ae926 100644 --- a/ogcore/aggregates.py +++ b/ogcore/aggregates.py @@ -418,6 +418,12 @@ def revenue( payroll_tax_revenue = get_payroll_tax_revenue( w, L, iit_payroll_tax_revenue, p, method ) + # When payroll taxes are modeled explicitly via tau_payroll, they are + # excluded from the income and payroll tax functions, so payroll tax + # revenue must be added into iit_payroll_tax_revenue (which enters + # total revenue) before the income tax portion is separated out. + if np.any(p.tau_payroll != 0): + iit_payroll_tax_revenue += payroll_tax_revenue business_tax_revenue = tax.get_biz_tax(w, Y, L, K, p_m, p, m, method).sum( -1 ) diff --git a/tests/test_aggregates.py b/tests/test_aggregates.py index 82b5695ad..751b72cdb 100644 --- a/tests/test_aggregates.py +++ b/tests/test_aggregates.py @@ -1474,111 +1474,111 @@ def test_get_C(c, p, method, expected): # vector of output prices p_m = np.ones((p.T, p.M)) -expected1 = 0.5688319028341413 +expected1 = 0.7598207140204407 expected2 = np.array( [ - 0.58978896, - 0.5318829, - 0.58291302, - 0.56616446, - 0.60152253, - 0.63685373, - 0.60718972, - 0.56236328, - 0.56929121, - 0.60536959, - 0.58763365, - 0.59627562, - 0.55409009, - 0.56782614, - 0.56400569, - 0.6636463, - 0.59160813, - 0.64735391, - 0.72066489, - 0.64096484, - 0.61899218, - 0.58806093, - 0.54783766, - 0.5393597, - 0.55685316, - 0.65395071, - 0.58946501, - 0.64129696, - 0.58759922, - 0.5580478, + 0.78077777, + 0.72068128, + 0.77564557, + 0.75220586, + 0.78498646, + 0.82226742, + 0.78953295, + 0.75499567, + 0.75017449, + 0.79555133, + 0.77677308, + 0.78748408, + 0.73859649, + 0.75895348, + 0.75059024, + 0.85599023, + 0.77630208, + 0.84016811, + 0.90505241, + 0.82363203, + 0.80717282, + 0.77141901, + 0.73973490, + 0.72241447, + 0.74309837, + 0.84927868, + 0.78312174, + 0.83015615, + 0.77981290, + 0.74258374, ] ) expected3 = ( np.array( [ - 0.58978896, - 0.5318829, - 0.58291302, - 0.56616446, - 0.60152253, - 0.63685373, - 0.60718972, - 0.56236328, - 0.56929121, - 0.60536959, - 0.58763365, - 0.59627562, - 0.55409009, - 0.56782614, - 0.56400569, - 0.6636463, - 0.59160813, - 0.64735391, - 0.72066489, - 0.64096484, - 0.61899218, - 0.58806093, - 0.54783766, - 0.5393597, - 0.55685316, - 0.65395071, - 0.58946501, - 0.64129696, - 0.58759922, - 0.5580478, + 0.78077777, + 0.72068128, + 0.77564557, + 0.75220586, + 0.78498646, + 0.82226742, + 0.78953295, + 0.75499567, + 0.75017449, + 0.79555133, + 0.77677308, + 0.78748408, + 0.73859649, + 0.75895348, + 0.75059024, + 0.85599023, + 0.77630208, + 0.84016811, + 0.90505241, + 0.82363203, + 0.80717282, + 0.77141901, + 0.73973490, + 0.72241447, + 0.74309837, + 0.84927868, + 0.78312174, + 0.83015615, + 0.77981290, + 0.74258374, ] ) - inv_tax_cred_rev3 ) -expected4 = 0.5688319028341413 +expected4 = 0.7598207140204407 expected5 = np.array( [ - 0.58978896, - 0.5318829, - 0.58291302, - 0.56616446, - 0.60152253, - 0.63685373, - 0.60718972, - 0.56236328, - 0.56929121, - 0.60536959, - 0.58763365, - 0.59627562, - 0.55409009, - 0.56782614, - 0.56400569, - 0.6636463, - 0.59160813, - 0.64735391, - 0.72066489, - 0.64096484, - 0.61899218, - 0.58806093, - 0.54783766, - 0.5393597, - 0.55685316, - 0.65395071, - 0.58946501, - 0.64129696, - 0.58759922, - 0.5580478, + 0.78077777, + 0.72068128, + 0.77564557, + 0.75220586, + 0.78498646, + 0.82226742, + 0.78953295, + 0.75499567, + 0.75017449, + 0.79555133, + 0.77677308, + 0.78748408, + 0.73859649, + 0.75895348, + 0.75059024, + 0.85599023, + 0.77630208, + 0.84016811, + 0.90505241, + 0.82363203, + 0.80717282, + 0.77141901, + 0.73973490, + 0.72241447, + 0.74309837, + 0.84927868, + 0.78312174, + 0.83015615, + 0.77981290, + 0.74258374, ] ) test_data = [