diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b377563..8b7304f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ 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). +## [Unreleased] + +### Added + +- New parameter `initial_wealth_ratio` (default 0.0 = disabled): household + wealth to GDP ratio in the initial period of the transition path, anchoring + B(0) = initial_wealth_ratio x steady-state Y. Initial wealth is a + predetermined state, so the anchor is STATIC within the solve, and + steady-state GDP is the anchor base because the steady-state solve has + already pinned it down exactly. Reform runs ignore the parameter and clone + the baseline's initial wealth (read from the baseline's saved transition), + so baseline and reform always share the same initial condition. Anchoring + to initial-period GDP instead was tried and rejected twice: Y(0) is + endogenous, and rescaling the households' initial wealth between + outer-loop iterations -- even damped -- drives the initial cohorts' + root-finding into infeasible negative-consumption roots that satisfy the + extended FOCs and pass the constraint checker. The transition path otherwise imposes the + steady-state wealth profile rescaled so aggregate initial wealth equals the + steady-state aggregate; when the initial age distribution is far from the + stationary one this hands every initial household a large uniform wealth + windfall (younger population) or confiscation (older population), producing + artificial consumption/investment swings in the first years of any baseline + transition. The new parameter makes initial wealth calibratable to data; + the default reproduces the previous behavior exactly. + ## [0.19.0] - 2026-07-29 12:00:00 ### Added diff --git a/ogcore/TPI.py b/ogcore/TPI.py index 5ecf7b936..120b7f64c 100644 --- a/ogcore/TPI.py +++ b/ogcore/TPI.py @@ -233,6 +233,35 @@ def get_initial_SS_values(p): return initial_values, ss_vars, theta, baseline_values +def scale_initial_wealth( + initial_b_shape, B0_shape, target_B0, factor, initial_n, p +): + """ + Rescale the initial wealth distribution to a target aggregate. + + Args: + initial_b_shape (Numpy array): SxJ unscaled initial wealth profile + B0_shape (scalar): aggregate of initial_b_shape over the initial + population + target_B0 (scalar): target aggregate initial wealth + factor (scalar): income scaling factor + initial_n (Numpy array): initial labor supply + p (OG-Core Specifications object): model parameters + + Returns: + (tuple): rescaled initial period values, + (B0, b_sinit, b_splus1init, factor, initial_b, initial_n) + + """ + scale = target_B0 / B0_shape + initial_b = initial_b_shape * scale + b_sinit = np.array( + list(np.zeros(p.J).reshape(1, p.J)) + list(initial_b[:-1]) + ) + b_splus1init = initial_b + return (target_B0, b_sinit, b_splus1init, factor, initial_b, initial_n) + + def firstdoughnutring( guesses, r, @@ -758,6 +787,30 @@ def run_TPI(p, client=None): Kg0_baseline, ) = baseline_values + # Anchor initial household wealth when initial_wealth_ratio is set (> 0). + # Initial wealth is a predetermined state, so the anchor is STATIC within + # the solve (rescaling it between outer-loop iterations -- even damped -- + # drives the initial cohorts' root-finding into infeasible negative- + # consumption roots that satisfy the extended FOCs). A baseline run sets + # aggregate initial wealth to initial_wealth_ratio times steady-state + # GDP, which the steady-state solve has already pinned down exactly; a + # reform run clones the baseline's initial wealth outright (the initial + # state is history -- policy cannot change what households start with). + anchor_initial_wealth = p.initial_wealth_ratio > 0 + if anchor_initial_wealth: + if p.baseline: + target_B0 = p.initial_wealth_ratio * ss_vars["Y"] + else: + baseline_tpi = os.path.join(p.baseline_dir, "TPI", "TPI_vars.pkl") + tpi_baseline_vars = utils.safe_read_pickle(baseline_tpi) + target_B0 = tpi_baseline_vars["B"][0] + initial_values = scale_initial_wealth( + initial_b, B0, target_B0, factor, initial_n, p + ) + B0, b_sinit, b_splus1init, factor, initial_b, initial_n = ( + initial_values + ) + # Create time path of UBI household benefits and aggregate UBI outlays ubi = p.ubi_nom_array / factor UBI = aggr.get_L(ubi[: p.T], p, "TPI") @@ -1179,6 +1232,7 @@ def run_TPI(p, client=None): ) # Update aggregate variables L[: p.T] = aggr.get_L(n_mat[: p.T], p, "TPI") + B[0] = B0 B[1 : p.T] = aggr.get_B(bmat_splus1[: p.T], p, "TPI", False)[: p.T - 1] w_open = firm.get_w_from_r(p.world_int_rate[: p.T], p, "TPI") diff --git a/ogcore/default_parameters.json b/ogcore/default_parameters.json index 150cdd4b5..60cdb4868 100644 --- a/ogcore/default_parameters.json +++ b/ogcore/default_parameters.json @@ -1103,6 +1103,24 @@ } } }, + "initial_wealth_ratio": { + "title": "Aggregate household wealth in the initial period, relative to steady-state GDP", + "description": "Anchors aggregate household wealth in the initial period of the transition path: B(0) = initial_wealth_ratio x steady-state Y, with the age profile keeping the steady-state shape. Steady-state GDP is the anchor base because it is pinned down exactly before the transition solves, making the anchor static (initial wealth is a predetermined state). Reform runs ignore the parameter and clone the baseline run's initial wealth, so baseline and reform always share the same initial condition. The default of 0.0 disables the anchor and reproduces the long-standing behavior, in which aggregate initial wealth is set equal to its steady-state level regardless of the initial population.", + "section_1": "Household Parameters", + "notes": "Calibrate so the solved initial-period wealth-to-GDP ratio matches observed household wealth (capital stock plus domestically held government debt) relative to GDP in the start year: set to the data ratio times the model's Y(0)/Y_ss (one solve iteration pins it; report the delivered B(0)/Y(0)). With the anchor disabled, an initial age distribution far from the stationary one implies a large uniform wealth windfall (younger population) or confiscation (older population) for all initial households.", + "type": "float", + "value": [ + { + "value": 0.0 + } + ], + "validators": { + "range": { + "min": 0.0, + "max": 20.0 + } + } + }, "r_gov_scale": { "title": "Scale parameter to determine government interest rate", "description": "Parameter to scale the market interest rate to find interest rate on government debt.", diff --git a/tests/test_TPI.py b/tests/test_TPI.py index ed424a347..9c6df1050 100644 --- a/tests/test_TPI.py +++ b/tests/test_TPI.py @@ -258,6 +258,35 @@ def test_get_initial_SS_values(baseline, param_updates, filename, tmpdir): ) +def test_scale_initial_wealth(): + """scale_initial_wealth rescales the wealth profile uniformly to a target + aggregate, keeping the profile's shape and rebuilding the beginning- and + end-of-period views consistently.""" + p = Specifications(baseline=True, num_workers=NUM_WORKERS) + rng = np.random.default_rng(5) + initial_b_shape = rng.uniform(0.1, 2.0, (p.S, p.J)) + B0_shape = 3.0 + initial_n = rng.uniform(0.2, 0.5, (p.S, p.J)) + target_B0 = 4.5 + (B0, b_sinit, b_splus1init, factor, initial_b, n_out) = ( + TPI.scale_initial_wealth( + initial_b_shape, B0_shape, target_B0, 1000.0, initial_n, p + ) + ) + assert B0 == target_B0 + assert np.allclose(initial_b, initial_b_shape * (target_B0 / B0_shape)) + assert np.allclose(b_splus1init, initial_b) + assert np.allclose(b_sinit[0, :], np.zeros(p.J)) + assert np.allclose(b_sinit[1:, :], initial_b[:-1, :]) + assert np.allclose(n_out, initial_n) + + +def test_initial_wealth_ratio_default_is_off(): + """The default of 0.0 disables the anchor (legacy behavior).""" + p = Specifications(baseline=True, num_workers=NUM_WORKERS) + assert p.initial_wealth_ratio == 0.0 + + def test_firstdoughnutring(): # Test TPI.firstdoughnutring function. Provide inputs to function and # ensure that output returned matches what it has been before.