Skip to content

API Reference: Solvers & Solutions

Container for perturbation outputs.

Attributes:

Name Type Description
decision_rule RecursiveDecisionRule

First-order recursive decision rule.

Source code in src/dyno/solver.py
class PerturbationSolution:
    """Container for perturbation outputs.

    Attributes
    ----------
    decision_rule: RecursiveDecisionRule
        First-order recursive decision rule.
    """

    def __init__(
        self: Self,
        decision_rule: RecursiveDecisionRule,
        evs: TVector | None = None,
    ) -> None:
        self.decision_rule = decision_rule
        self.evs = evs

    def __getattr__(self, name: str) -> Any:
        if name.startswith("__") and name.endswith("__"):
            raise AttributeError(name)
        dr = self.__dict__.get("decision_rule")
        if dr is None:
            raise AttributeError(name)
        return getattr(dr, name)

VAR(1) representing a linearized model

Attributes:

Name Type Description
X, Y, Σ (N,N) Matrix

parameters of the stationary VAR process \(y_t = Xy_{t-1} + Yε_t\), where Σ is the covariance matrix of \(ε_t\)

symbols dict[str, list[str]]

dictionary containing the symbols used in the model, the only allowed keys are "endogenous", "exogenous" and "parameters"

x0 N Vector | None

the state around which the linearization is done, generally the steady state, by default None

Source code in src/dyno/solver.py
class RecursiveDecisionRule:
    """VAR(1) representing a linearized model

    Attributes
    ----------
    X, Y, Σ: (N,N) Matrix
        parameters of the stationary VAR process $y_t = Xy_{t-1} + Yε_t$, where Σ is the covariance matrix of $ε_t$

    symbols: dict[str, list[str]]
        dictionary containing the symbols used in the model, the only allowed keys are "endogenous", "exogenous" and "parameters"

    x0: N Vector | None
        the state around which the linearization is done, generally the steady state, by default None

    """

    def __init__(
        self: Self,
        X: TMatrix,
        Y: TMatrix,
        Σ: TMatrix,
        symbols: dict[str, list[str]],
        x0: TVector | None = None,
        model: "AbstractModel | None" = None,
    ) -> None:

        self.x0 = x0
        self.X = X
        self.Y = Y
        self.Σ = Σ

        self.symbols = symbols
        self._model = model

    def moments(self):

        return moments(self.X, self.Y, self.Σ)

    def coefficients_as_df(self):
        import pandas as pd

        assert self.x0 is not None

        ss = pd.DataFrame(
            [self.x0], columns=["{}".format(e) for e in self.symbols["endogenous"]]
        )
        hh_y = self.X
        hh_e = self.Y
        df = pd.DataFrame(
            np.concatenate([hh_y, hh_e], axis=1),
            columns=["{}[t-1]".format(e) for e in self.symbols["endogenous"]]
            + ["{}[t]".format(e) for e in (self.symbols["exogenous"])],
        )
        df.index = pd.Index(["{}[t]".format(e) for e in self.symbols["endogenous"]])
        return ss, df

    def _repr_html_(self):

        Σ0, Σ = moments(self.X, self.Y, self.Σ)

        ss, df = self.coefficients_as_df()

        html = f"""
        <h3>Decision Rule</h3>
        <h4>Steady-state</h4>
        {ss.to_html(index=False)}
        <h4>Jacobian</h4>
        {df.to_html()}
        """
        return html

    def irfs(self, type: IRFType = "log-deviation", T: int = 40) -> "IRFSimulation":

        from .simul import irfs

        assert self._model is not None

        sim = irfs(self._model, self, type=type, T=T)
        return sim

    def simulate(
        self,
        T: int = 40,
        mode: SimulateMode = "auto",
        N: int = 1,
        units: UnitsType = "deviation",
        shocks: dict[str, dict[int, float]] | np.ndarray | None = None,
        initial_states: dict[str, float] | np.ndarray | None = None,
        rng: np.random.Generator | int | None = None,
    ) -> "SimulationResult":
        """Simulate the solved model.

        Parameters
        ----------
        T : int, default 40
            Simulation horizon.
        mode : {'auto', 'irf', 'random', 'transition', 'deterministic'}, default 'auto'
            When ``'auto'``, defaults to ``'irf'`` unless ``N > 1``, ``shocks``, or
            ``initial_states`` are explicitly supplied.
        N : int, default 1
            Number of Monte Carlo trajectories when ``mode='random'``.
        units : {'deviation', 'level', 'percent', 'log-deviation'}, default 'deviation'
            Default output units.
        rng : numpy.random.Generator | int | None, optional
            Generator or seed for the random shocks when ``mode='random'``.
        """
        from .simul import simulate

        resolved_mode: SimulateMode
        if mode == "auto":
            if shocks is not None or initial_states is not None or N > 1:
                resolved_mode = "random"
            else:
                resolved_mode = "irf"
        else:
            resolved_mode = mode

        if resolved_mode == "irf":
            return self.irfs(type=units, T=T)

        return simulate(
            self,
            T=T,
            shocks=shocks,
            initial_states=initial_states,
            N=N,
            mode="random",
            units=units,
            rng=rng,
        )

    def plot(
        self,
        type: IRFType = "log-deviation",
        units: UnitsType | None = None,
        variables: list[str] | None = None,
        T: int = 40,
        **kwargs: Any,
    ):
        target_units: UnitsType = units if units is not None else type
        sim = self.irfs(type=target_units, T=T)
        return sim.plot(variables=variables, T=T, units=target_units, **kwargs)

simulate(T=40, mode='auto', N=1, units='deviation', shocks=None, initial_states=None, rng=None)

Simulate the solved model.

Parameters:

Name Type Description Default
T int

Simulation horizon.

40
mode ('auto', 'irf', 'random', 'transition', 'deterministic')

When 'auto', defaults to 'irf' unless N > 1, shocks, or initial_states are explicitly supplied.

'auto'
N int

Number of Monte Carlo trajectories when mode='random'.

1
units ('deviation', 'level', 'percent', 'log-deviation')

Default output units.

'deviation'
rng Generator | int | None

Generator or seed for the random shocks when mode='random'.

None
Source code in src/dyno/solver.py
def simulate(
    self,
    T: int = 40,
    mode: SimulateMode = "auto",
    N: int = 1,
    units: UnitsType = "deviation",
    shocks: dict[str, dict[int, float]] | np.ndarray | None = None,
    initial_states: dict[str, float] | np.ndarray | None = None,
    rng: np.random.Generator | int | None = None,
) -> "SimulationResult":
    """Simulate the solved model.

    Parameters
    ----------
    T : int, default 40
        Simulation horizon.
    mode : {'auto', 'irf', 'random', 'transition', 'deterministic'}, default 'auto'
        When ``'auto'``, defaults to ``'irf'`` unless ``N > 1``, ``shocks``, or
        ``initial_states`` are explicitly supplied.
    N : int, default 1
        Number of Monte Carlo trajectories when ``mode='random'``.
    units : {'deviation', 'level', 'percent', 'log-deviation'}, default 'deviation'
        Default output units.
    rng : numpy.random.Generator | int | None, optional
        Generator or seed for the random shocks when ``mode='random'``.
    """
    from .simul import simulate

    resolved_mode: SimulateMode
    if mode == "auto":
        if shocks is not None or initial_states is not None or N > 1:
            resolved_mode = "random"
        else:
            resolved_mode = "irf"
    else:
        resolved_mode = mode

    if resolved_mode == "irf":
        return self.irfs(type=units, T=T)

    return simulate(
        self,
        T=T,
        shocks=shocks,
        initial_states=initial_states,
        N=N,
        mode="random",
        units=units,
        rng=rng,
    )

Solves AX² + BX + C = 0 for X using the chosen method

Parameters:

Name Type Description Default
A (N,N) Matrix
required
B (N,N) Matrix
required
C (N,N) Matrix
required
method str

chosen solver: either "ti" for fixed-point iteration or "qz" for generalized Schur decomposition, by default "qz"

'qz'
options dict

dictionary of optional parameters to pass to the chosen solver, by default {}

None

Returns:

Type Description
(X, evs) : tuple[(N,N) Matrix, 2N Vector|None]

solution of the equation as well as sorted list of associated generalized eigenvalues if the chosen method is "qz" and None otherwise

Raises:

Type Description
NoConvergence :

when the convergence threshold is not reached within the maximal allowed number of iterations (only in solve_ti)

LinAlgError :

when a singular matrix is obtained during iterations in solve_ti or when Blachard-Kahn conditions are not verified in solve_qz

ValueError :

when a matrix containing a NaN is obtained

Source code in src/dyno/solver.py
def solve(
    A: TMatrix,
    B: TMatrix,
    C: TMatrix,
    method: Solver = "qz",
    options: dict | None = None,
) -> tuple[TMatrix, TVector | None]:
    """Solves AX² + BX + C = 0 for X using the chosen method

    Parameters
    ----------
    A, B, C : (N,N) Matrix

    method : str, optional
        chosen solver: either "ti" for fixed-point iteration or "qz" for generalized Schur decomposition, by default "qz"

    options : dict, optional
        dictionary of optional parameters to pass to the chosen solver, by default {}

    Returns
    -------
    (X, evs) : tuple[(N,N) Matrix, 2N Vector|None]
        solution of the equation as well as sorted list of associated generalized eigenvalues if the chosen method is "qz" and None otherwise

    Raises
    ------
    NoConvergence :
        when the convergence threshold is not reached within the maximal allowed number of iterations (only in `solve_ti`)
    LinAlgError :
        when a singular matrix is obtained during iterations in `solve_ti` or when Blachard-Kahn conditions are not verified in `solve_qz`
    ValueError :
        when a matrix containing a NaN is obtained
    """

    options = options or {}

    if method == "ti":
        sol, evs = solve_ti(A, B, C, **options)
    elif method == "qz":
        sol, evs = solve_qz(A, B, C, **options)
    else:
        raise ValueError(
            f"Unknown solver method: {method!r}. Valid choices are 'ti' and 'qz'."
        )

    return sol, evs

Solves AX² + BX + C = 0 for X using QZ decomposition.

Parameters:

Name Type Description Default
A (N,N) Matrix
required
B (N,N) Matrix
required
C (N,N) Matrix
required
tol float

error tolerance, by default 1e-15

1e-15

Returns:

Type Description
(X, evs) : tuple[(N,N) Matrix, 2N Vector]

solution of the equation as well as sorted list of associated generalized eigenvalues

Raises:

Type Description
LinAlgError :

when Blanchard-Kahn conditions are not verified (less than N generalized eigenvalues inside the unit ball)

Source code in src/dyno/solver.py
def solve_qz(
    A: TMatrix, B: TMatrix, C: TMatrix, tol: float = 1e-15
) -> tuple[TMatrix, TVector]:
    """Solves AX² + BX + C = 0 for X using QZ decomposition.

    Parameters
    ----------
    A, B, C : (N,N) Matrix

    tol : float, optional
        error tolerance, by default 1e-15

    Returns
    -------
    (X, evs) : tuple[(N,N) Matrix, 2N Vector]
        solution of the equation as well as sorted list of associated generalized eigenvalues

    Raises
    ------
    LinAlgError :
        when Blanchard-Kahn conditions are not verified (less than N generalized eigenvalues inside the unit ball)
    """
    from scipy.linalg import ordqz

    n = A.shape[0]
    if n == 0:
        return np.zeros((0, 0)), np.array([], dtype=float)
    I = np.eye(n)
    Z = np.zeros((n, n))

    # Generalised eigenvalue problem
    F = np.block([[Z, I], [-C, -B]])
    G = np.block([[I, Z], [Z, A]])
    tol_sort = 1e-6
    T, S, α, β, Q, Z = ordqz(F, G, sort=lambda a, b: np.abs(vgenev(a, b, tol=tol_sort)) <= 1 + tol_sort)  # type: ignore
    λ_all = vgenev(α, β, tol=tol_sort)
    Z11, Z12, Z21, Z22 = decompose_blocks(Z)

    λ_all = np.abs(λ_all)

    # TODO: verify whether Blanchard-Kahn conditions are valid
    evs = np.sort(λ_all).reshape(2 * n)
    n = len(evs) // 2
    l1 = evs[n - 1]
    l2 = evs[n]
    if l1 <= l2 < 1:
        raise BlanchardKahnError(
            f"Eigenvalue condition not satisfied: l_(n)={l1}, l_(n+1)={l2}. Too many stable solutions.",
            evs=evs,
        )
    if 1 < l1 <= l2:
        raise BlanchardKahnError(
            f"Eigenvalue condition not satisfied: l_(n)={l1}, l_(n+1)={l2}. No stable solution.",
            evs=evs,
        )

    X = (Z21 @ np.linalg.inv(Z11)).reshape(
        (n, n)
    )  # Reshape necessary for static type checking

    return X, evs

Solves AX² + BX + C = 0 for X using fixed-point iteration.

Parameters:

Name Type Description Default
A (N,N) Matrix
required
B (N,N) Matrix
required
C (N,N) Matrix
required
T int

Maximum number of iterations. If more are needed, NoConvergence is raised, by default 10000

10000
tol float

convergence threshold, by default 1e-10

1e-10

Returns:

Type Description
(X, evs) : tuple[(N,N) Matrix, None]

solution of the equation and None (necessary to have a common solver interface)

Raises:

Type Description
NoConvergence :

when the convergence threshold is not reached within the maximal allowed number of iterations

LinAlgError :

when a singular matrix is obtained while iterating

ValueError :

when a matrix containing a NaN is obtained while iterating

Source code in src/dyno/solver.py
def solve_ti(
    A: TMatrix, B: TMatrix, C: TMatrix, T: int = 10000, tol: float = 1e-10
) -> tuple[TMatrix, None]:
    """Solves AX² + BX + C = 0 for X using fixed-point iteration.

    Parameters
    ----------
    A, B, C : (N,N) Matrix

    T : int, optional
        Maximum number of iterations. If more are needed, `NoConvergence` is raised, by default 10000

    tol : float, optional
        convergence threshold, by default 1e-10

    Returns
    -------
    (X, evs) : tuple[(N,N) Matrix, None]
        solution of the equation and None (necessary to have a common solver interface)

    Raises
    ------
    NoConvergence :
        when the convergence threshold is not reached within the maximal allowed number of iterations
    LinAlgError :
        when a singular matrix is obtained while iterating
    ValueError :
        when a matrix containing a NaN is obtained while iterating
    """
    n = A.shape[0]

    # Deterministic initial guess: the solution of the backward-looking part
    # (A = 0). Falls back to zeros when B is singular.
    X0: TMatrix
    try:
        X0 = np.asarray(linsolve(B, -C), dtype=float).reshape((n, n))
    except np.linalg.LinAlgError:
        X0 = np.zeros((n, n))

    for t in range(T):

        X1 = linsolve(A @ X0 + B, -C)
        e = abs(X0 - X1).max()

        if np.isnan(e):
            # impossible situation?
            raise ValueError("Invalid value")

        X0 = X1
        if e < tol:
            return X0, None

    raise NoConvergence("The maximal number of iterations was exceeded.")

Source code in src/dyno/solver.py
def deterministic_solve(
    model,
    x0=None,
    T=None,
    method="hybr",
    verbose=False,
    continuation="stationary",
    growth_rate=None,
    growth_type="geometric",
    return_iterations=False,
    units: UnitsType = "level",
    **args,
):
    from .simul import TransitionSimulation

    continuation = args.pop("terminal_condition", continuation)
    growth_rate = args.pop("growth_rate", growth_rate)
    growth_type = args.pop("growth_type", growth_type)

    if x0 is None:
        v0 = model.deterministic_guess(T=T)
    else:
        v0 = np.array(x0)

    T = v0.shape[0] - 1

    u0 = np.array(v0).ravel()

    newton_res = newton(
        lambda u: model.deterministic_residuals_with_jacobian(
            u,
            sparsify=True,
            continuation=continuation,
            growth_rate=growth_rate,
            growth_type=growth_type,
            **args,
        ),
        u0,
        jactype="sparse",
        verbose=verbose,
        maxit=args.get("maxit", 10),
        tol=args.get("tol", 1e-8),
    )

    u_sol, nit = newton_res[0], newton_res[1]
    converged = getattr(newton_res, "converged", True)
    final_error = getattr(newton_res, "error", None)

    w0 = u_sol.reshape(v0.shape)

    variables = list(model.symbols["variables"])
    c = getattr(model, "context", {})
    ss_map = c.get("steady_states", {})
    val_map = c.get("values", {})
    ss_vec = np.array(
        [ss_map.get(name, val_map.get(name, {}).get(0, np.nan)) for name in variables],
        dtype=float,
    )

    attrs: dict[str, Any] = {
        "iterations": nit,
        "converged": converged,
    }
    if final_error is not None:
        attrs["residual"] = final_error

    sim = TransitionSimulation(
        data=w0,
        variables=variables,
        steady_state=ss_vec,
        units=units,
        canonical_units="level",
        attrs=attrs,
    )

    if return_iterations:
        return sim, nit

    return sim

Computes conditional and unconditional moments of stationary process \(y_t = X y_{t-1} + Y e_t\)

Parameters:

Name Type Description Default
X (N,N) Matrix

Transition matrix defining the stochastic process.

required
Y (N,N) Matrix

Shock impact matrix defining the stochastic process.

required
Sigma (N,N) Matrix

Covariance matrix of the independent identically distributed error terms e_t.

required

Returns:

Type Description
Gamma_0, Gamma : (N,N) Matrix

Conditional and unconditional covariance matrices of the stationary process y_t respectively.

Notes

The unconditional covariance matrix Γ is computed in the following way:

Applying the linear covariance operator to both sides of the equation \(y_t = X y_{t-1} + Y e_t\) yields $$ \mathrm{Cov}(y_t) = X ⋅ \mathrm{Cov}(y_{t-1}) ⋅ X^* + Y ⋅ \mathrm{Cov}(e_t) ⋅ Y^* $$ By stationarity of \(y_t\), \(\mathrm{Cov}(y_t) = \mathrm{Cov}(y_{t-1}) := Γ\), so $$ Γ = X Γ X^* + Γ₀ $$ By applying the Vec-operator, we get the following equation: $$ \mathrm{Vec}(Γ) = (X ⊗ X) \mathrm{Vec}(Γ) + \mathrm{Vec}(Γ₀) $$ Which gives the following solution $$ \mathrm{Vec}(Γ) = (I_{N^2} - X ⊗ X)^{-1} \mathrm{Vec}(Γ₀) $$

Source code in src/dyno/solver.py
def moments(X: TMatrix, Y: TMatrix, Σ: TMatrix) -> tuple[TMatrix, TMatrix]:
    """
    Computes conditional and unconditional moments of stationary process $y_t = X y_{t-1} + Y e_t$

    Parameters
    ----------
    X : (N,N) Matrix
        Transition matrix defining the stochastic process.
    Y : (N,N) Matrix
        Shock impact matrix defining the stochastic process.
    Sigma : (N,N) Matrix
        Covariance matrix of the independent identically distributed error terms e_t.

    Returns
    -------
    Gamma_0, Gamma : (N,N) Matrix
        Conditional and unconditional covariance matrices of the stationary process y_t respectively.

    Notes
    -----
    The unconditional covariance matrix Γ is computed in the following way:

    Applying the linear covariance operator to both sides of the equation $y_t = X y_{t-1} + Y e_t$ yields
    $$
    \\mathrm{Cov}(y_t) = X ⋅ \\mathrm{Cov}(y_{t-1}) ⋅ X^* + Y ⋅ \\mathrm{Cov}(e_t) ⋅ Y^*
    $$
    By stationarity of $y_t$, $\\mathrm{Cov}(y_t) = \\mathrm{Cov}(y_{t-1}) := Γ$, so
    $$
    Γ = X Γ X^* + Γ₀
    $$
    By applying the [Vec-operator](https://en.wikipedia.org/wiki/Vectorization_(mathematics)#Compatibility_with_Kronecker_products), we get the following equation:
    $$
    \\mathrm{Vec}(Γ) = (X ⊗ X) \\mathrm{Vec}(Γ)  + \\mathrm{Vec}(Γ₀)
    $$
    Which gives the following solution
    $$
    \\mathrm{Vec}(Γ) = (I_{N^2} - X ⊗ X)^{-1} \\mathrm{Vec}(Γ₀)
    $$
    """

    Γ0 = Y @ Σ @ Y.T
    n = X.shape[0]

    # Compute the unconditional variance
    Γ = (np.linalg.inv(np.eye(n**2) - np.kron(X, X)) @ Γ0.flatten()).reshape(n, n)

    return Γ0, Γ