dsgelab 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dsgelab/__init__.py +129 -0
- dsgelab/bgp.py +141 -0
- dsgelab/blocks.py +304 -0
- dsgelab/calibration.py +208 -0
- dsgelab/cli.py +44 -0
- dsgelab/diagnostics.py +279 -0
- dsgelab/dynare.py +1541 -0
- dsgelab/estimate.py +276 -0
- dsgelab/estimation.py +719 -0
- dsgelab/examples/__init__.py +5 -0
- dsgelab/examples/_heutel_source.py +209 -0
- dsgelab/examples/heutel.py +210 -0
- dsgelab/examples/nk.py +55 -0
- dsgelab/examples/rbc.py +92 -0
- dsgelab/export.py +276 -0
- dsgelab/extended_path.py +355 -0
- dsgelab/foresight.py +443 -0
- dsgelab/kalman.py +609 -0
- dsgelab/klein.py +252 -0
- dsgelab/linearize.py +252 -0
- dsgelab/model.py +285 -0
- dsgelab/occbin.py +363 -0
- dsgelab/optimal.py +866 -0
- dsgelab/priors.py +168 -0
- dsgelab/projection.py +237 -0
- dsgelab/py.typed +0 -0
- dsgelab/secondorder.py +402 -0
- dsgelab/solution.py +491 -0
- dsgelab/solve.py +52 -0
- dsgelab/steady.py +366 -0
- dsgelab/symbolic.py +396 -0
- dsgelab/thirdorder.py +370 -0
- dsgelab/welfare.py +226 -0
- dsgelab-0.5.0.dist-info/METADATA +332 -0
- dsgelab-0.5.0.dist-info/RECORD +39 -0
- dsgelab-0.5.0.dist-info/WHEEL +5 -0
- dsgelab-0.5.0.dist-info/entry_points.txt +2 -0
- dsgelab-0.5.0.dist-info/licenses/LICENSE +21 -0
- dsgelab-0.5.0.dist-info/top_level.txt +1 -0
dsgelab/__init__.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""dsgelab -- a general toolkit for solving DSGE models.
|
|
2
|
+
|
|
3
|
+
A model is declared once, as a list of state names, a list of jump names, a list
|
|
4
|
+
of shock names and one function returning the equilibrium residuals. Everything
|
|
5
|
+
else -- steady state, first-order solution, impulse responses, moments,
|
|
6
|
+
second-order risk corrections, nonlinear perfect-foresight transitions,
|
|
7
|
+
calibration on targets, optimal policy and welfare -- is computed from that
|
|
8
|
+
single declaration, so the stochastic block and the deterministic transition
|
|
9
|
+
cannot drift apart into two models that merely look alike.
|
|
10
|
+
|
|
11
|
+
Quick start::
|
|
12
|
+
|
|
13
|
+
import dsgelab as dsg
|
|
14
|
+
from dsgelab.examples import rbc
|
|
15
|
+
|
|
16
|
+
model, params, guess = rbc.build()
|
|
17
|
+
sol = dsg.solve(model, params, guess=guess)
|
|
18
|
+
print(sol.summary())
|
|
19
|
+
irf = sol.irf("eps_a", size=0.01, periods=40)
|
|
20
|
+
|
|
21
|
+
The canonical form behind it is Klein's::
|
|
22
|
+
|
|
23
|
+
A E_t x_{t+1} = B x_t + C eps_{t+1}, u_t = F k_t, k_{t+1} = P k_t + Q eps_{t+1}
|
|
24
|
+
|
|
25
|
+
with the Blanchard-Kahn condition enforced rather than reported.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .calibration import CalibrationResult, calibrate, target_jacobian
|
|
31
|
+
from .diagnostics import (blanchard_kahn_report, check_identity, check_slack,
|
|
32
|
+
compare_irfs, diagnose, steady_state_invariance)
|
|
33
|
+
from .bgp import BalancedGrowth, balanced_growth
|
|
34
|
+
from .extended_path import ExtendedPath, extended_path, quadrature_nodes
|
|
35
|
+
from .foresight import News, TransitionPath, perfect_foresight, perfect_foresight_with_expectation_errors
|
|
36
|
+
from .klein import BlanchardKahnError, KleinSolution, klein
|
|
37
|
+
from .linearize import (Coordinates, LinearSystem, first_order_system, second_derivatives,
|
|
38
|
+
third_derivatives)
|
|
39
|
+
from .model import Model, VarView, get_param, set_params
|
|
40
|
+
from .dynare import DynareImportError, ModFile, from_mod, read_expectation_errors_csv
|
|
41
|
+
from .occbin import OccbinError, OccbinModel, OccbinResult, occbin_simulate
|
|
42
|
+
from .optimal import (DiscretionError, DiscretionSolution, OSRError, OSRResult, PrivateBlock,
|
|
43
|
+
RamseyError, discretionary_policy, osr, ramsey_model, ramsey_multipliers,
|
|
44
|
+
ramsey_steady_state)
|
|
45
|
+
from .welfare import Welfare, WelfareError, consumption_equivalent, welfare, welfare_model
|
|
46
|
+
from .secondorder import SecondOrderSolution, solve_second_order
|
|
47
|
+
from .thirdorder import ThirdOrderSolution, solve_third_order
|
|
48
|
+
from .estimate import EstimationResult, estimate, log_likelihood, log_posterior
|
|
49
|
+
from .estimation import (EstimationProblem, MCMCResult, ModeResult, SMCResult,
|
|
50
|
+
effective_sample_size, maximum_likelihood, metropolis_hastings,
|
|
51
|
+
model_probabilities, posterior_forecast, posterior_mode,
|
|
52
|
+
prior_predictive, rhat, smc)
|
|
53
|
+
from .kalman import (FilterResult, Forecast, SmootherResult, StateSpace, forecast,
|
|
54
|
+
kalman_filter, kalman_smoother, realtime_shock_decomposition,
|
|
55
|
+
shock_decomposition, state_space)
|
|
56
|
+
from .priors import Prior, prior
|
|
57
|
+
from .projection import Projection, solve_projection
|
|
58
|
+
from .solution import FirstOrderSolution, shock_covariance
|
|
59
|
+
from .solve import solve
|
|
60
|
+
from .steady import SteadyState, SteadyStateError, analytic_steady_state, steady_state
|
|
61
|
+
|
|
62
|
+
__version__ = "0.5.0"
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"Model", "VarView", "get_param", "set_params",
|
|
66
|
+
"steady_state", "SteadyState", "analytic_steady_state", "SteadyStateError",
|
|
67
|
+
"solve", "FirstOrderSolution", "shock_covariance",
|
|
68
|
+
"klein", "KleinSolution", "BlanchardKahnError",
|
|
69
|
+
"first_order_system", "second_derivatives", "LinearSystem", "Coordinates",
|
|
70
|
+
"solve_second_order", "SecondOrderSolution",
|
|
71
|
+
"solve_third_order", "ThirdOrderSolution", "third_derivatives",
|
|
72
|
+
"estimate", "EstimationResult", "log_likelihood", "log_posterior",
|
|
73
|
+
"EstimationProblem", "posterior_mode", "maximum_likelihood", "metropolis_hastings", "smc",
|
|
74
|
+
"prior_predictive", "model_probabilities", "posterior_forecast", "rhat",
|
|
75
|
+
"effective_sample_size", "ModeResult", "MCMCResult", "SMCResult",
|
|
76
|
+
"state_space", "StateSpace", "kalman_filter", "kalman_smoother", "FilterResult",
|
|
77
|
+
"SmootherResult", "shock_decomposition", "realtime_shock_decomposition", "forecast",
|
|
78
|
+
"Forecast", "prior", "Prior",
|
|
79
|
+
"solve_projection", "Projection",
|
|
80
|
+
"perfect_foresight", "TransitionPath", "perfect_foresight_with_expectation_errors", "News",
|
|
81
|
+
"calibrate", "CalibrationResult", "target_jacobian",
|
|
82
|
+
"check_identity", "check_slack", "blanchard_kahn_report", "compare_irfs",
|
|
83
|
+
"steady_state_invariance", "diagnose",
|
|
84
|
+
"ramsey_model", "ramsey_steady_state", "ramsey_multipliers", "RamseyError",
|
|
85
|
+
"discretionary_policy", "DiscretionSolution", "DiscretionError", "PrivateBlock",
|
|
86
|
+
"osr", "OSRResult", "OSRError",
|
|
87
|
+
"welfare", "welfare_model", "Welfare", "WelfareError", "consumption_equivalent",
|
|
88
|
+
"from_mod", "ModFile", "DynareImportError", "read_expectation_errors_csv",
|
|
89
|
+
"OccbinModel", "OccbinResult", "OccbinError", "occbin_simulate",
|
|
90
|
+
"extended_path", "ExtendedPath", "quadrature_nodes", "balanced_growth", "BalancedGrowth",
|
|
91
|
+
"__version__",
|
|
92
|
+
"from_equations", "optimality_conditions", "assemble", "to_dynare", "to_macromodelling",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def from_equations(*args: Any, **kwargs: Any) -> Model:
|
|
97
|
+
"""Declare a model as text equations. Requires sympy; see :mod:`dsgelab.symbolic`."""
|
|
98
|
+
from .symbolic import from_equations as _fe
|
|
99
|
+
return _fe(*args, **kwargs)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def optimality_conditions(*args: Any, **kwargs: Any) -> dict:
|
|
103
|
+
"""Derive first-order conditions from a stated optimisation problem.
|
|
104
|
+
|
|
105
|
+
Requires sympy; see :mod:`dsgelab.blocks`.
|
|
106
|
+
"""
|
|
107
|
+
from .blocks import optimality_conditions as _oc
|
|
108
|
+
return _oc(*args, **kwargs)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def assemble(*args: Any, **kwargs: Any) -> Model:
|
|
112
|
+
"""Join several optimisation problems and closure equations into one Model.
|
|
113
|
+
|
|
114
|
+
Requires sympy; see :func:`dsgelab.blocks.assemble`.
|
|
115
|
+
"""
|
|
116
|
+
from .blocks import assemble as _as
|
|
117
|
+
return _as(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def to_dynare(*args: Any, **kwargs: Any) -> str:
|
|
121
|
+
"""Write a symbolically-declared model as a Dynare ``.mod``. See :mod:`dsgelab.export`."""
|
|
122
|
+
from .export import to_dynare as _td
|
|
123
|
+
return _td(*args, **kwargs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def to_macromodelling(*args: Any, **kwargs: Any) -> str:
|
|
127
|
+
"""Write a symbolically-declared model for MacroModelling.jl. See :mod:`dsgelab.export`."""
|
|
128
|
+
from .export import to_macromodelling as _tm
|
|
129
|
+
return _tm(*args, **kwargs)
|
dsgelab/bgp.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The balanced growth path of a model written in levels.
|
|
2
|
+
|
|
3
|
+
Dynare's ``bgp.write``: every variable is assumed to grow at a constant factor,
|
|
4
|
+
``x_t = X g^t``, and the model must hold at two consecutive dates,
|
|
5
|
+
|
|
6
|
+
f(X g, X, 0) = 0, f(X g^2, X g, 0) = 0,
|
|
7
|
+
|
|
8
|
+
``2n`` equations in the ``n`` levels ``X`` and the ``n`` growth factors ``g``. The
|
|
9
|
+
growth factors are identified; the level of a growing variable is not -- only its
|
|
10
|
+
ratio to the variables that share its trend is -- so the system is solved by
|
|
11
|
+
least squares (Levenberg--Marquardt) from a start, as Dynare's tests do, and the
|
|
12
|
+
answer reports which variables grow.
|
|
13
|
+
|
|
14
|
+
For a ``from_mod`` model the variables are Dynare's, with Dynare's dating: the
|
|
15
|
+
level of ``v`` is the one of the period whose equations are evaluated first, and an
|
|
16
|
+
auxiliary ``LAG1_v`` grows with ``v``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Mapping
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from numpy.typing import ArrayLike
|
|
26
|
+
from scipy.optimize import least_squares
|
|
27
|
+
|
|
28
|
+
from .model import Model, Params
|
|
29
|
+
|
|
30
|
+
__all__ = ["BalancedGrowth", "balanced_growth"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class BalancedGrowth:
|
|
35
|
+
"""Levels and growth factors along a balanced growth path."""
|
|
36
|
+
|
|
37
|
+
levels: dict
|
|
38
|
+
growth: dict
|
|
39
|
+
residual: float
|
|
40
|
+
model: Model = field(repr=False)
|
|
41
|
+
success: bool = True
|
|
42
|
+
degenerate: bool = False
|
|
43
|
+
|
|
44
|
+
def ratio(self, a: str, b: str) -> float:
|
|
45
|
+
"""``a/b`` along the path; meaningful when both share a trend."""
|
|
46
|
+
if abs(self.growth[a] - self.growth[b]) > 1e-8 * max(1.0, abs(self.growth[b])):
|
|
47
|
+
raise ValueError(f"{a} and {b} grow at different factors: their ratio is not constant")
|
|
48
|
+
return self.levels[a] / self.levels[b]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def growing(self) -> list[str]:
|
|
52
|
+
return [v for v, g in self.growth.items() if abs(g - 1) > 1e-8]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def bgp_residual(model: Model, params: Params, X: ArrayLike, G: ArrayLike,
|
|
56
|
+
ctx: Any = None) -> np.ndarray:
|
|
57
|
+
"""``[f(X g, X, 0); f(X g^2, X g, 0)]``: the two dates of a balanced growth path."""
|
|
58
|
+
X, G = np.asarray(X, dtype=float), np.asarray(G, dtype=float)
|
|
59
|
+
e = np.zeros(model.n_shocks)
|
|
60
|
+
return np.concatenate([model.residuals(X * G, X, e, params, ctx),
|
|
61
|
+
model.residuals(X * G * G, X * G, e, params, ctx)])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def balanced_growth(model: Model, params: Params, *,
|
|
65
|
+
guess: dict[str, float] | ArrayLike | None = None,
|
|
66
|
+
growth: dict[str, float] | ArrayLike | None = None,
|
|
67
|
+
fixed: Mapping[str, float] | None = None, ctx: Any = None,
|
|
68
|
+
tol: float = 1e-12, seed: int | None = 0, max_nfev: int = 100000,
|
|
69
|
+
tries: int = 20) -> BalancedGrowth:
|
|
70
|
+
"""Solve for the balanced growth path.
|
|
71
|
+
|
|
72
|
+
``guess`` and ``growth`` are starting levels and growth factors, as mappings or
|
|
73
|
+
vectors in the model's order (by default, levels drawn around 1 with ``seed``,
|
|
74
|
+
as in Dynare's tests, and growth factors of 1). ``fixed`` maps variables that
|
|
75
|
+
do not grow to their level -- the states that carry exogenous variables, which
|
|
76
|
+
Dynare holds at zero -- and removes them from the unknowns.
|
|
77
|
+
|
|
78
|
+
A model in levels often has trivial paths too, on which a factor of production
|
|
79
|
+
is zero and output with it. A solution with a free level at zero is set aside
|
|
80
|
+
and the search restarts from another random start, up to ``tries`` times; if
|
|
81
|
+
every start ends there, the first solution comes back with ``degenerate=True``.
|
|
82
|
+
"""
|
|
83
|
+
n = model.n_vars
|
|
84
|
+
rng = np.random.default_rng(seed)
|
|
85
|
+
fixed = dict(fixed or {})
|
|
86
|
+
fix_idx = np.array([model.position(k) for k in fixed], dtype=int)
|
|
87
|
+
free = np.setdiff1d(np.arange(n), fix_idx)
|
|
88
|
+
fix_val = np.array([float(v) for v in fixed.values()])
|
|
89
|
+
|
|
90
|
+
def vec(v, default):
|
|
91
|
+
if v is None:
|
|
92
|
+
return default
|
|
93
|
+
if isinstance(v, dict):
|
|
94
|
+
out = default.copy()
|
|
95
|
+
for k, x in v.items():
|
|
96
|
+
out[model.position(k)] = float(x)
|
|
97
|
+
return out
|
|
98
|
+
return np.asarray(v, dtype=float).copy()
|
|
99
|
+
|
|
100
|
+
e = np.zeros(model.n_shocks)
|
|
101
|
+
m = free.size
|
|
102
|
+
|
|
103
|
+
def full(z):
|
|
104
|
+
X, G = np.empty(n), np.ones(n)
|
|
105
|
+
X[free], G[free] = z[:m], z[m:]
|
|
106
|
+
X[fix_idx] = fix_val
|
|
107
|
+
return X, G
|
|
108
|
+
|
|
109
|
+
def F(z):
|
|
110
|
+
return bgp_residual(model, params, *full(z), ctx)
|
|
111
|
+
|
|
112
|
+
def jac(z):
|
|
113
|
+
X, G = full(z)
|
|
114
|
+
J1 = model.jacobian(X * G, X, e, params, ctx)
|
|
115
|
+
J2 = model.jacobian(X * G * G, X * G, e, params, ctx)
|
|
116
|
+
A1, B1 = J1[:, :n], J1[:, n:2 * n]
|
|
117
|
+
A2, B2 = J2[:, :n], J2[:, n:2 * n]
|
|
118
|
+
top = np.hstack([(A1 * G + B1)[:, free], (A1 * X)[:, free]])
|
|
119
|
+
bot = np.hstack([(A2 * G * G + B2 * G)[:, free], (A2 * (2 * X * G) + B2 * X)[:, free]])
|
|
120
|
+
return np.vstack([top, bot])
|
|
121
|
+
|
|
122
|
+
use_exact = None
|
|
123
|
+
first = None
|
|
124
|
+
for attempt in range(max(1, tries)):
|
|
125
|
+
X0 = vec(guess if attempt == 0 else None, 1 + (rng.random(n) - 0.5) * 0.2)
|
|
126
|
+
G0 = vec(growth, np.ones(n))
|
|
127
|
+
if use_exact is None:
|
|
128
|
+
use_exact = model.jacobian(X0 * G0, X0, e, params, ctx) is not None
|
|
129
|
+
sol = least_squares(F, np.concatenate([X0[free], G0[free]]),
|
|
130
|
+
jac=jac if use_exact else "2-point", method="lm",
|
|
131
|
+
xtol=1e-15, ftol=1e-15, gtol=1e-15, max_nfev=max_nfev)
|
|
132
|
+
X, G = full(sol.x)
|
|
133
|
+
res = float(np.max(np.abs(F(sol.x))))
|
|
134
|
+
degenerate = bool(np.min(np.abs(X[free]), initial=np.inf) < 1e-6)
|
|
135
|
+
out = BalancedGrowth(levels=dict(zip(model.names, X)), growth=dict(zip(model.names, G)),
|
|
136
|
+
residual=res, model=model, success=res < tol, degenerate=degenerate)
|
|
137
|
+
if out.success and not degenerate:
|
|
138
|
+
return out
|
|
139
|
+
if first is None or (out.success and not first.success):
|
|
140
|
+
first = out
|
|
141
|
+
return first
|
dsgelab/blocks.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Derive the first-order conditions from an agent's problem.
|
|
2
|
+
|
|
3
|
+
Optional: requires ``sympy`` (``pip install dsgelab[symbolic]``).
|
|
4
|
+
|
|
5
|
+
What it does, and what it does not
|
|
6
|
+
----------------------------------
|
|
7
|
+
It takes a declared recursive optimisation problem (the period objective, the choice variables,
|
|
8
|
+
the states and the constraints) and returns the FOCs as EQUATIONS IN TEXT, ready for
|
|
9
|
+
:func:`dsgelab.symbolic.from_equations`. The gain is not in typing: the algebra is no longer done
|
|
10
|
+
by hand. Deriving a Lagrangian with four constraints and three states by hand is where sign errors
|
|
11
|
+
and forgotten terms come in, and they do not show up in the steady state.
|
|
12
|
+
|
|
13
|
+
What it does NOT do is choose the problem for you. Transversality conditions, inequality
|
|
14
|
+
constraints, complementarity and non-standard envelopes remain the modeller's job: the package
|
|
15
|
+
derives what you declared, and the declaration is the modelling.
|
|
16
|
+
|
|
17
|
+
The mathematics
|
|
18
|
+
---------------
|
|
19
|
+
With ``y`` the controls, ``k`` the states and ``h_j`` the constraints written as ``= 0``, the
|
|
20
|
+
period Lagrangian is
|
|
21
|
+
|
|
22
|
+
L_t = u(y_t, k_t) + SUM_j lm_j,t * h_j(y_t, k_t, k_{t+1})
|
|
23
|
+
|
|
24
|
+
and the conditions are
|
|
25
|
+
|
|
26
|
+
dL_t/dy_t = 0 for each control
|
|
27
|
+
dL_t/dk_{t+1} + beta * E_t[ dL_{t+1}/dk_{t+1} ] = 0 for each state
|
|
28
|
+
|
|
29
|
+
The second is the Euler equation, and its second term is the derivative of the NEXT period's
|
|
30
|
+
Lagrangian with respect to the state it inherits, obtained by shifting ``dL_t/dk_t`` one period
|
|
31
|
+
ahead. If that shift produces a term in ``t+2``, the problem cannot be represented in two periods
|
|
32
|
+
and the function refuses, naming the state that caused it, instead of returning wrong equations.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
from typing import TYPE_CHECKING, Mapping, Sequence
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from .model import Model
|
|
42
|
+
|
|
43
|
+
__all__ = ["FOCError", "optimality_conditions", "Problem", "assemble"]
|
|
44
|
+
|
|
45
|
+
_LEAD = re.compile(r"\b([A-Za-z_]\w*)\s*\(\s*\+?\s*1\s*\)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FOCError(ValueError):
|
|
49
|
+
"""Raised for a malformed problem, an undeclared name or a two-period-ahead term."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _require_sympy():
|
|
53
|
+
try:
|
|
54
|
+
import sympy # noqa: F401
|
|
55
|
+
except ImportError as exc: # pragma: no cover
|
|
56
|
+
raise FOCError("deriving FOCs requires sympy: "
|
|
57
|
+
"pip install 'dsgelab[symbolic]'") from exc
|
|
58
|
+
import sympy
|
|
59
|
+
return sympy
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse(text, local_dict, global_dict, label):
|
|
63
|
+
from sympy.parsing.sympy_parser import parse_expr, standard_transformations
|
|
64
|
+
t = _LEAD.sub(r"\1__next", str(text))
|
|
65
|
+
if t.count("=") > 1:
|
|
66
|
+
raise FOCError(f"{label}: more than one '='")
|
|
67
|
+
if "=" in t:
|
|
68
|
+
lhs, rhs = t.split("=")
|
|
69
|
+
t = f"({lhs}) - ({rhs})"
|
|
70
|
+
try:
|
|
71
|
+
return parse_expr(t, local_dict=local_dict, global_dict=global_dict,
|
|
72
|
+
transformations=standard_transformations)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
raise FOCError(f"{label}: cannot parse {text!r} ({exc})") from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Problem:
|
|
78
|
+
"""A declared recursive optimisation problem.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
objective : str
|
|
83
|
+
The PERIOD return, ``u(y_t, k_t)``. The discount factor enters through ``discount``, not
|
|
84
|
+
here.
|
|
85
|
+
controls : sequence of str
|
|
86
|
+
Variables chosen at ``t`` that are not states.
|
|
87
|
+
states : sequence of str
|
|
88
|
+
Predetermined variables. The value chosen at ``t`` is ``k(+1)``.
|
|
89
|
+
constraints : sequence of str
|
|
90
|
+
Equality constraints, ``"lhs = rhs"`` or an expression that equals zero. Each one gets a
|
|
91
|
+
multiplier.
|
|
92
|
+
discount : str
|
|
93
|
+
Name of the discount parameter.
|
|
94
|
+
parameters : sequence of str
|
|
95
|
+
Every name that is neither a variable nor a multiplier. An undeclared name raises an error
|
|
96
|
+
instead of becoming a free symbol: a typo cannot turn into a new parameter.
|
|
97
|
+
exogenous : sequence of str, optional
|
|
98
|
+
Variables that enter the problem but the agent does NOT choose, such as productivity or
|
|
99
|
+
prices taken as given. They produce no FOC.
|
|
100
|
+
multipliers : sequence of str, optional
|
|
101
|
+
Names of the multipliers, in the order of the constraints. When absent they are called
|
|
102
|
+
``lm_1``, ``lm_2``…
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, objective: str, *, controls: Sequence[str], states: Sequence[str] = (),
|
|
106
|
+
constraints: Sequence[str] = (), discount: str = "beta",
|
|
107
|
+
parameters: Sequence[str] = (), exogenous: Sequence[str] = (),
|
|
108
|
+
multipliers: Sequence[str] | None = None, name: str = "agent"):
|
|
109
|
+
self.objective = objective
|
|
110
|
+
self.controls = list(controls)
|
|
111
|
+
self.states = list(states)
|
|
112
|
+
self.constraints = list(constraints)
|
|
113
|
+
self.discount = discount
|
|
114
|
+
self.parameters = list(parameters)
|
|
115
|
+
self.exogenous = list(exogenous)
|
|
116
|
+
self.name = name
|
|
117
|
+
n = len(self.constraints)
|
|
118
|
+
self.multipliers = (list(multipliers) if multipliers is not None
|
|
119
|
+
else [f"lm_{i + 1}" for i in range(n)])
|
|
120
|
+
if len(self.multipliers) != n:
|
|
121
|
+
raise FOCError(f"{n} constraints and {len(self.multipliers)} multipliers")
|
|
122
|
+
all_names = self.controls + self.states + self.exogenous + self.multipliers
|
|
123
|
+
dup = {v for v in all_names if all_names.count(v) > 1}
|
|
124
|
+
if dup:
|
|
125
|
+
raise FOCError(f"names repeated among variables and multipliers: {sorted(dup)}")
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return (f"Problem({self.name!r}, {len(self.controls)} controls, "
|
|
129
|
+
f"{len(self.states)} states, {len(self.constraints)} constraints)")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def optimality_conditions(problem: Problem) -> dict:
|
|
133
|
+
"""Derive the FOCs. Returns text equations, ready for ``from_equations``.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
dict with
|
|
138
|
+
``equations`` the FOCs plus the constraints, as strings;
|
|
139
|
+
``jumps`` controls and multipliers, the non-predetermined variables that the
|
|
140
|
+
problem introduces;
|
|
141
|
+
``states`` the states, in the declared order;
|
|
142
|
+
``multipliers`` the names used;
|
|
143
|
+
``detail`` a map from each equation to its origin (which control, which state, which
|
|
144
|
+
constraint), so that the result can be audited rather than being an opaque
|
|
145
|
+
block.
|
|
146
|
+
"""
|
|
147
|
+
sympy = _require_sympy()
|
|
148
|
+
p = problem
|
|
149
|
+
|
|
150
|
+
variables = p.controls + p.states + p.exogenous + p.multipliers
|
|
151
|
+
cur = {v: sympy.Symbol(v) for v in variables}
|
|
152
|
+
nxt = {v: sympy.Symbol(f"{v}__next") for v in variables}
|
|
153
|
+
nxt2 = {v: sympy.Symbol(f"{v}__next2") for v in variables}
|
|
154
|
+
par = {q: sympy.Symbol(q) for q in list(p.parameters) + [p.discount]}
|
|
155
|
+
|
|
156
|
+
local = {}
|
|
157
|
+
local.update(cur)
|
|
158
|
+
local.update({f"{v}__next": nxt[v] for v in variables})
|
|
159
|
+
local.update(par)
|
|
160
|
+
known = set(cur.values()) | set(nxt.values()) | set(par.values())
|
|
161
|
+
functions = {"log": sympy.log, "exp": sympy.exp, "sqrt": sympy.sqrt, "Abs": sympy.Abs,
|
|
162
|
+
"sin": sympy.sin, "cos": sympy.cos, "Min": sympy.Min, "Max": sympy.Max}
|
|
163
|
+
local_dict = dict(local); local_dict.update(functions)
|
|
164
|
+
global_dict = {"Symbol": sympy.Symbol, "Integer": sympy.Integer,
|
|
165
|
+
"Float": sympy.Float, "Rational": sympy.Rational,
|
|
166
|
+
"factorial": sympy.factorial}
|
|
167
|
+
|
|
168
|
+
def parse_checked(text, label):
|
|
169
|
+
e = _parse(text, local_dict, global_dict, label)
|
|
170
|
+
unknown = {s for s in e.free_symbols if s not in known}
|
|
171
|
+
if unknown:
|
|
172
|
+
raise FOCError(
|
|
173
|
+
f"{label}: undeclared name(s) {sorted(str(s) for s in unknown)}. "
|
|
174
|
+
f"Declare them in controls, states, exogenous or parameters.")
|
|
175
|
+
return e
|
|
176
|
+
|
|
177
|
+
u = parse_checked(p.objective, "objective")
|
|
178
|
+
hs = [parse_checked(c, f"constraint {i + 1}") for i, c in enumerate(p.constraints)]
|
|
179
|
+
|
|
180
|
+
# period Lagrangian
|
|
181
|
+
L = u + sum(cur[m] * h for m, h in zip(p.multipliers, hs))
|
|
182
|
+
|
|
183
|
+
def lead(expr):
|
|
184
|
+
"""Shift the expression one period ahead: x -> x(+1), x(+1) -> x(+2)."""
|
|
185
|
+
return expr.xreplace({nxt[v]: nxt2[v] for v in variables}) \
|
|
186
|
+
.xreplace({cur[v]: nxt[v] for v in variables})
|
|
187
|
+
|
|
188
|
+
printer = _plain_printer(sympy)
|
|
189
|
+
equations, detail, jump_order = [], {}, []
|
|
190
|
+
|
|
191
|
+
# (1) FOC for each control
|
|
192
|
+
for c in p.controls:
|
|
193
|
+
# No `simplify`: it sometimes puts the expression over a denominator that is a VARIABLE,
|
|
194
|
+
# and a residual divided by k(+1) is equivalent only while k(+1) stays away from zero.
|
|
195
|
+
# The raw derivative is exactly the FOC.
|
|
196
|
+
foc = sympy.expand(sympy.diff(L, cur[c]))
|
|
197
|
+
txt = printer(foc)
|
|
198
|
+
equations.append(txt)
|
|
199
|
+
detail[txt] = f"FOC for control {c}"
|
|
200
|
+
|
|
201
|
+
# (2) Euler equation for each state
|
|
202
|
+
for k in p.states:
|
|
203
|
+
now = sympy.diff(L, nxt[k]) # effect of choosing k(+1) today
|
|
204
|
+
later = lead(sympy.diff(L, cur[k])) # what k(+1) yields tomorrow
|
|
205
|
+
forbidden = [v for v in variables if nxt2[v] in later.free_symbols]
|
|
206
|
+
if forbidden:
|
|
207
|
+
raise FOCError(
|
|
208
|
+
f"the Euler equation for state {k!r} requires terms in t+2 ({sorted(forbidden)}): "
|
|
209
|
+
f"the problem cannot be represented in two periods. Introduce the variable at t+1 "
|
|
210
|
+
f"as a control with its own definition.")
|
|
211
|
+
euler = sympy.expand(now + par[p.discount] * later)
|
|
212
|
+
txt = printer(euler)
|
|
213
|
+
equations.append(txt)
|
|
214
|
+
detail[txt] = f"Euler equation for state {k}"
|
|
215
|
+
|
|
216
|
+
# (3) the constraints, which are also equations of the model
|
|
217
|
+
for i, (c, h) in enumerate(zip(p.constraints, hs), start=1):
|
|
218
|
+
txt = printer(h)
|
|
219
|
+
equations.append(txt)
|
|
220
|
+
detail[txt] = f"constraint {i}: {c}"
|
|
221
|
+
|
|
222
|
+
jump_order = list(p.controls) + list(p.multipliers)
|
|
223
|
+
return dict(equations=equations, jumps=jump_order, states=list(p.states),
|
|
224
|
+
multipliers=list(p.multipliers), detail=detail)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _plain_printer(sympy):
|
|
228
|
+
"""Print in the syntax of ``from_equations``: ``x__next`` becomes ``x(+1)`` again."""
|
|
229
|
+
from sympy.printing.str import StrPrinter
|
|
230
|
+
pr = StrPrinter(settings={"order": "none"})
|
|
231
|
+
|
|
232
|
+
def do(expr):
|
|
233
|
+
s = pr.doprint(expr)
|
|
234
|
+
return re.sub(r"\b([A-Za-z_]\w*)__next\b", r"\1(+1)", s)
|
|
235
|
+
|
|
236
|
+
return do
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def assemble(problems: Sequence[Problem], *, equations: Sequence[str] = (),
|
|
240
|
+
states: Sequence[str] = (), jumps: Sequence[str] = (),
|
|
241
|
+
shocks: Sequence[str] = (), parameters: Sequence[str] = (),
|
|
242
|
+
name: str = "model", derivatives: str = "both") -> Model:
|
|
243
|
+
"""Combine several optimisation problems and the closing equations into one ``Model``.
|
|
244
|
+
|
|
245
|
+
Each problem contributes its FOCs, its constraints and its multipliers
|
|
246
|
+
(:func:`optimality_conditions`). The closure -- market clearing, exogenous
|
|
247
|
+
processes, definitions -- is the modeller's text, in ``equations``, and the
|
|
248
|
+
variables that only the closure introduces (prices, aggregate shocks) are
|
|
249
|
+
declared in ``states`` and ``jumps``. This is not a block language: the limits
|
|
250
|
+
are those of :class:`Problem` (equality constraints, nothing in ``t+2``).
|
|
251
|
+
|
|
252
|
+
What is checked before assembling: a variable chosen by two agents is refused
|
|
253
|
+
(a price that both take as given is ``exogenous`` to both); multipliers whose
|
|
254
|
+
default name is repeated across agents get the agent's name as a prefix
|
|
255
|
+
(``household_lm_1``); and the number of equations must equal the number of
|
|
256
|
+
variables, with the count per block in the message when it does not. The
|
|
257
|
+
origin of each equation is kept in ``model.assembly``.
|
|
258
|
+
"""
|
|
259
|
+
from .symbolic import from_equations
|
|
260
|
+
|
|
261
|
+
chosen: dict = {}
|
|
262
|
+
for p in problems:
|
|
263
|
+
for v in p.controls + p.states:
|
|
264
|
+
if v in chosen:
|
|
265
|
+
raise FOCError(f"{v!r} is chosen by two agents, {chosen[v]!r} and {p.name!r}: "
|
|
266
|
+
f"one of them must take it as given (exogenous)")
|
|
267
|
+
chosen[v] = p.name
|
|
268
|
+
names = [p.name for p in problems]
|
|
269
|
+
if len(set(names)) != len(names):
|
|
270
|
+
raise FOCError(f"two problems share a name: {names}")
|
|
271
|
+
all_mult = [m for p in problems for m in p.multipliers]
|
|
272
|
+
clash = {m for m in all_mult if all_mult.count(m) > 1}
|
|
273
|
+
renamed = {}
|
|
274
|
+
eqs, st, jp, origin = [], [], [], []
|
|
275
|
+
for p in problems:
|
|
276
|
+
if clash & set(p.multipliers):
|
|
277
|
+
new = [f"{p.name}_{m}" if m in clash else m for m in p.multipliers]
|
|
278
|
+
renamed.update({f"{p.name}_{m}": (p.name, m) for m in p.multipliers if m in clash})
|
|
279
|
+
p = Problem(p.objective, controls=p.controls, states=p.states,
|
|
280
|
+
constraints=p.constraints, discount=p.discount,
|
|
281
|
+
parameters=p.parameters, exogenous=p.exogenous, multipliers=new,
|
|
282
|
+
name=p.name)
|
|
283
|
+
foc = optimality_conditions(p)
|
|
284
|
+
for e in foc["equations"]:
|
|
285
|
+
eqs.append(e)
|
|
286
|
+
origin.append((p.name, foc["detail"].get(e, "")))
|
|
287
|
+
st += [s for s in foc["states"] if s not in st]
|
|
288
|
+
jp += [j for j in foc["jumps"] if j not in jp]
|
|
289
|
+
for e in equations:
|
|
290
|
+
eqs.append(e)
|
|
291
|
+
origin.append(("closure", e))
|
|
292
|
+
st += [s for s in states if s not in st]
|
|
293
|
+
jp += [j for j in jumps if j not in jp and j not in st]
|
|
294
|
+
n = len(st) + len(jp)
|
|
295
|
+
if len(eqs) != n:
|
|
296
|
+
counts = {nm: sum(1 for o in origin if o[0] == nm) for nm in names + ["closure"]}
|
|
297
|
+
raise FOCError(f"{len(eqs)} equations for {n} variables ({len(st)} states, "
|
|
298
|
+
f"{len(jp)} jumps); equations by block: {counts}")
|
|
299
|
+
params = sorted(set(parameters) | {q for p in problems for q in p.parameters}
|
|
300
|
+
| {p.discount for p in problems})
|
|
301
|
+
model = from_equations(eqs, states=st, jumps=jp, shocks=list(shocks), parameters=params,
|
|
302
|
+
name=name, derivatives=derivatives)
|
|
303
|
+
model.assembly = dict(origin=origin, renamed_multipliers=renamed, agents=names)
|
|
304
|
+
return model
|