Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
15 changes: 13 additions & 2 deletions Documentation/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ For more information on HARK, see [our Github organization](https://github.com/e

## Changes

### 0.16.0

Under development

#### Major Changes

- Adds `HARK.algos.vbi` as a general algorithm for solving the optimization step of an agent's problem. [#1438](https://github.com/econ-ark/HARK/pull/1438)

#### Minor Changes


### 0.15.0

Release Date: June 4, 2024
Expand All @@ -16,7 +27,7 @@ Note: Due to major changes on this release, you may need to adjust how AgentType

This release drops support for Python 3.8 and 3.9, consistent with SPEC 0, and adds support for Python 3.11 and 3.12. We expect that all HARK features still work with the older versions, but they are no longer part of our testing regimen.

### Major Changes
#### Major Changes

- Drop official support for Python 3.8 and 3.9, add support for 3.11 and 3.12. [#1415](https://github.com/econ-ark/HARK/pull/1415)
- Replace object-oriented solvers with single function versions. [#1394](https://github.com/econ-ark/HARK/pull/1394)
Expand All @@ -28,7 +39,7 @@ This release drops support for Python 3.8 and 3.9, consistent with SPEC 0, and a
- Such constructed inputs can use alternate parameterizations / formats by changing the `constructor` function and providing its arguments in `parameters`.
- Move `HARK.datasets` to `HARK.Calibration` for better organization of data and calibration tools. [#1430](https://github.com/econ-ark/HARK/pull/1430)

### Minor Changes
#### Minor Changes

- Add option to pass pre-built grid to `LinearFast`. [#1388](https://github.com/econ-ark/HARK/pull/1388)
- Moves calculation of stable points out of ConsIndShock solver, into method called by post_solve [#1349](https://github.com/econ-ark/HARK/pull/1349)
Expand Down
1 change: 1 addition & 0 deletions Documentation/reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ API Reference
:caption: Tools
:maxdepth: 1

tools/algos
tools/core
tools/dcegm
tools/distribution
Expand Down
7 changes: 7 additions & 0 deletions Documentation/reference/tools/algos.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
algos
--------

.. toctree::
:maxdepth: 3

algos/vbi
7 changes: 7 additions & 0 deletions Documentation/reference/tools/algos/vbi.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
algos.vbi
----------

.. automodule:: HARK.algos.vbi
:members:
:undoc-members:
:show-inheritance:
27 changes: 27 additions & 0 deletions Documentation/reference/tools/index.rst.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
:orphan:

Tools
=====

<<<<<<< HEAD
.. toctree::
:maxdepth: 3

algos
core
dcegm
distribution
econforgeinterp
estimation
frame
helpers
interpolation
numba
parallel
rewards
simulation
utilities
validators
=======
See :doc:`../index`.
>>>>>>> master
Comment on lines +6 to +27

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file contains unresolved merge conflict markers (<<<<<<< HEAD, =======, >>>>>>> master). These should be resolved before merging. This file appears to be a .orig backup file that should likely be removed from the repository entirely.

Copilot uses AI. Check for mistakes.
Empty file added HARK/algos/__init__.py
Empty file.
34 changes: 34 additions & 0 deletions HARK/algos/tests/test_vbi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import HARK.algos.vbi as vbi
from HARK.distribution import Bernoulli

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import statement uses HARK.distribution (singular) but based on the PR description mentioning "#1496" which reorganizes the distribution file into a distributions submodule, and seeing that all other files in the codebase use from HARK.distributions import, this should be from HARK.distributions import Bernoulli (plural).

Suggested change
from HARK.distribution import Bernoulli
from HARK.distributions import Bernoulli

Copilot uses AI. Check for mistakes.
from HARK.model import Control, DBlock
import numpy as np

import unittest


block_1 = DBlock(
**{
"name": "vbi_test_1",
"shocks": {
"coin": Bernoulli(p=0.5),
},
"dynamics": {
"m": lambda y, coin: y + coin,
"c": Control(["m"]),
"a": lambda m, c: m - c,
},
"reward": {"u": lambda c: 1 - (c - 1) ** 2},
}
)


class test_vbi(unittest.TestCase):

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class name test_vbi does not follow Python naming conventions for test classes, which should use PascalCase (e.g., TestVbi or TestVBI). This inconsistency may cause issues with some test discovery tools.

Suggested change
class test_vbi(unittest.TestCase):
class TestVbi(unittest.TestCase):

Copilot uses AI. Check for mistakes.
# def setUp(self):
# pass

def test_solve_block_1(self):
state_grid = {"m": np.linspace(0, 2, 10)}

dr, dec_vf, arr_vf = vbi.vbi_solve(block_1, lambda a: a, state_grid)

Comment on lines +40 to +47

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment appears to contain commented-out code.

Suggested change
# def setUp(self):
# pass
def test_solve_block_1(self):
state_grid = {"m": np.linspace(0, 2, 10)}
dr, dec_vf, arr_vf = vbi.solve(block_1, lambda a: a, state_grid)
def test_solve_block_1(self):
state_grid = {"m": np.linspace(0, 2, 10)}
dr, dec_vf, arr_vf = vbi.solve(block_1, lambda a: a, state_grid)
dr, dec_vf, arr_vf = vbi.solve(block_1, lambda a: a, state_grid)

Copilot uses AI. Check for mistakes.
self.assertAlmostEqual(dr["c"](**{"m": 1}), 0.5)
162 changes: 162 additions & 0 deletions HARK/algos/vbi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""
Use backwards induction to derive the arrival value function
from a continuation value function and stage dynamics.
"""

from HARK.model import DBlock
import itertools
import numpy as np
from scipy.optimize import minimize
from typing import Mapping, Sequence
import xarray as xr


def get_action_rule(action):
"""
Produce a function from any inputs to a given value.
This is useful for constructing decision rules with fixed actions.
"""

def ar():
return action

return ar


def ar_from_data(da):
"""
Produce a function from any inputs to a given value.
This is useful for constructing decision rules with fixed actions.
"""

def ar(**args):
return da.interp(**args).values.tolist()

return ar


Grid = Mapping[str, Sequence]


def grid_to_data_array(
grid: Grid = {}, ## TODO: Better data structure here.
):
"""
Construct a zero-valued DataArray with the coordinates
based on the Grid passed in.

Parameters
----------
grid: Grid
A mapping from variable labels to a sequence of numerical values.

Returns
--------
da xarray.DataArray
An xarray.DataArray with coordinates given by both grids.
"""

coords = {**grid}

da = xr.DataArray(
np.empty([len(v) for v in coords.values()]), dims=coords.keys(), coords=coords
)

return da


def vbi_solve(
block: DBlock, continuation, state_grid: Grid, disc_params={}, calibration={}
):
"""
Solve a DBlock using backwards induction on the value function.

Parameters
-----------
block
continuation

state_grid: Grid
This is a grid over all variables that the optimization will range over.
This should be just the information set of the decision variables.

disc_params
calibration
Comment on lines +76 to +85

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring for the solve function is incomplete. Parameters block, continuation, disc_params, and calibration are listed but not described. This makes the API unclear for users of this function.

Suggested change
-----------
block
continuation
state_grid: Grid
This is a grid over all variables that the optimization will range over.
This should be just the information set of the decision variables.
disc_params
calibration
----------
block : DBlock
The dynamic block to be solved. Provides the stage dynamics and
methods for constructing value and policy functions.
continuation
The continuation (arrival) value object used for backward induction,
typically a function or rule mapping future states to a value.
state_grid : Grid
A grid over all state variables that the optimization will range
over. This should correspond to the information set of the
decision variables.
disc_params : Mapping, optional
Discounting and/or preference parameters used in the value
calculation (for example, discount factors or risk aversion
parameters). Passed through to the block as needed.
calibration : Mapping, optional
Model calibration parameters (such as structural or environment
parameters) that are required to evaluate the block's dynamics.

Copilot uses AI. Check for mistakes.
"""

# state-rule value function
srv_function = block.get_state_rule_value_function_from_continuation(continuation)

controls = block.get_controls()

# pseudo

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "# pseudo" on line 95 is cryptic and doesn't explain what "pseudo" means in this context. Consider clarifying what these data arrays represent, e.g., "# Initialize empty arrays for policy and value function data".

Suggested change
# pseudo
# Initialize empty DataArrays over the state grid for policy and value function data

Copilot uses AI. Check for mistakes.
policy_data = grid_to_data_array(state_grid)
value_data = grid_to_data_array(state_grid)

# loop through every point in the state grid
for state_point in itertools.product(*state_grid.values()):
# build a dictionary from these states, as scope for the optimization
state_vals = {k: v for k, v in zip(state_grid.keys(), state_point)}

# The value of the action is computed given
# the problem calibration and the states for the current point on the
# state-grid.
pre_states = calibration.copy()
pre_states.update(state_vals)

# prepare function to optimize
def negated_value(a): # old! (should be negative)

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "old! (should be negative)" is unclear and potentially confusing. If this is outdated information, it should be removed. If it's meant to explain why the function is negated, consider clarifying the comment to say something like "negated for use with minimization optimizer".

Copilot uses AI. Check for mistakes.
dr = {c: get_action_rule(a[i]) for i, c in enumerate(controls)}

# negative, for minimization later
return -srv_function(pre_states, dr)

## get lower bound.
## not yet implemented
lower_bound = np.array([-1e-12] * len(controls)) ## a really low number!

## get upper bound
## not yet implemented
upper_bound = np.array([1e12] * len(controls))

# pseudo
# optimize_action(pre_states, srv_function)

res = minimize( # choice of
negated_value,
0, # x0 is starting guess, here arbitrary.
)
print(res)

dr_best = {c: get_action_rule(res.x[i]) for i, c in enumerate(controls)}

if res.success:
policy_data.sel(**state_vals).variable.data.put(
0, res.x[0]
) # will only work for scalar actions
value_data.sel(**state_vals).variable.data.put(
0, srv_function(pre_states, dr_best)
)
else:
print(f"Optimization failure at {state_vals}.")
print(root_res)

dr_best = {c: get_action_rule(root_res[i]) for i, c in enumerate(controls)}

policy_data.sel(**state_vals).variable.data.put(0, res.root) # ?
value_data.sel(**state_vals).variable.data.put(
0, srv_function(pre_states, dr_best)
)

# use the xarray interpolator to create a decision rule.
dr_from_data = {
c: ar_from_data(
policy_data
) # maybe needs to be more sensitive to the information set
for i, c in enumerate(controls)
}
Comment on lines +172 to +177

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment indicates this code "maybe needs to be more sensitive to the information set" (line 175), which suggests incomplete implementation. This should be addressed or documented as a known limitation if it's acceptable for the initial implementation.

Suggested change
dr_from_data = {
c: ar_from_data(
policy_data
) # maybe needs to be more sensitive to the information set
for i, c in enumerate(controls)
}
# Note: This implementation assumes that each control's decision rule
# depends only on the coordinates in `state_grid`. Any additional
# information-set dependence must be handled by extending this logic.
dr_from_data = {c: ar_from_data(policy_data) for c in controls}

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +177

Copilot AI Jan 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All controls share the same policy_data array in the decision rule construction. For problems with multiple controls, each control should have its own policy array. This will cause issues when the code is extended to handle multiple controls (currently it raises an exception for len(controls) > 1).

Suggested change
dr_from_data = {
c: ar_from_data(
policy_data
) # maybe needs to be more sensitive to the information set
for i, c in enumerate(controls)
}
if len(controls) == 0:
# no controls: empty decision rule
dr_from_data = {}
elif len(controls) == 1:
# single control: construct rule from the corresponding policy data
dr_from_data = {controls[0]: ar_from_data(policy_data)}
else:
# multiple controls are not yet supported in value backup iteration
raise Exception(
f"Value backup iteration is not yet implemented for stages with {len(controls)} > 1 control variables."
)

Copilot uses AI. Check for mistakes.

dec_vf = block.get_decision_value_function(dr_from_data, continuation)
arr_vf = block.get_arrival_value_function(disc_params, dr_from_data, continuation)

return dr_from_data, dec_vf, arr_vf
28 changes: 24 additions & 4 deletions HARK/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ class Control:

Parameters
----------
args : list of str
iset : list of str
The labels of the variables that are in the information set of this control.
"""

def __init__(self, args):
pass
def __init__(self, iset, upper_bound=None):
self.iset = iset
self.upper_bound = upper_bound


def discretized_shock_dstn(shocks, disc_params):
Expand Down Expand Up @@ -161,11 +162,30 @@ def get_dynamics(self):
def get_vars(self):
return list(self.shocks.keys()) + list(self.dynamics.keys())

def get_controls(self):
"""
TODO: Repeated in RBlock. Move to higher order class.
"""
dyn = self.get_dynamics()

return [varn for varn in dyn if isinstance(dyn[varn], Control)]

def transition(self, pre, dr):
"""
Returns variable values given previous values and decision rule for all controls.
"""
return simulate_dynamics(self.dynamics, pre, dr)
dyn = self.dynamics.copy()

# don't simulate values that have already been given.
# this will break if there's a directly recursive label,
# i.e. if dynamics at time t for variable 'a'
# depend on state of 'a' at time t-1
# This is a forbidden case in CDC's design.
for varn in pre:
if varn in dyn:
del dyn[varn]

return simulate_dynamics(dyn, pre, dr)

def calc_reward(self, vals):
"""
Expand Down
2 changes: 1 addition & 1 deletion HARK/models/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"dynamics": {
"b": lambda k, R, PermGroFac: k * R / PermGroFac,
"m": lambda b, theta: b + theta,
"c": Control(["m"]),
"c": Control(["m"], upper_bound=lambda m: m),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mnwhite @alanlujan91 I wonder what you think about this way of introducing upper/lower bound information on control variables.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good.

If we wanted to differentiate between a fixed (real number) upper bound, and a functional upper bound, we could use the term upper_envelope for a function/lambda.

"a": lambda m, c: m - c,
},
"reward": {"u": lambda c, CRRA: c ** (1 - CRRA) / (1 - CRRA)},
Expand Down