Source code for ogcore.solvers

"""Pluggable outer-loop update rules for the TPI fixed-point solve.

``run_TPI``'s outer loop computes an implied path ``G(x)`` from the current
guess ``x`` of the macro/price series ``{r_p, r, w, p_m, BQ[, TR]}``; the
update rule maps ``(x, G(x), history) -> x_next``. The default ``"picard"``
rule is the damped step ``x_next = (1 - nu) x + nu G(x)`` -- the model's
historical functional iteration (see the ``nu`` parameter) -- and ``run_TPI``
keeps its original ``convex_combo`` path for ``"picard"``, so the default
behavior (and golden outputs) are unchanged.

The ``"anderson"`` rule instead uses the recent residual history
``f = G(x) - x`` to take larger, better-directed (superlinear) steps, selected
via ``p.TPI_outer_method``. On its own Anderson can overshoot a strongly
nonlinear map into infeasible regions; ``run_TPI`` guards it with a trust
region anchored to the always-feasible damped point (see ``run_TPI``).
"""

import numpy as np


[docs] class AndersonAccelerator: r""" Anderson acceleration (type-II) with limited memory for the TPI outer loop. Given the residual :math:`f_k = G(x_k) - x_k` and the differences :math:`\Delta X, \Delta F` of the last ``m`` iterates and residuals, the update is .. math:: x_{k+1} = x_k + \beta f_k - (\Delta X + \beta\Delta F)\gamma, where :math:`\gamma` solves the least squares problem :math:`\min_{\gamma}\ \lVert f_k - \Delta F\gamma\rVert`. ``beta = 1`` is undamped; ``beta < 1`` adds damping for robustness far from the solution. The macro/price blocks differ in magnitude by orders (r ~ 0.05, BQ/TR large), which would swamp the least squares in raw units, so each element is scaled by a fixed reference captured on the first step (floored well away from zero) to put the whole vector in an O(1), dimensionless space. Args: m (int): number of previous iterates kept in the acceleration memory beta (float): mixing (relaxation) parameter applied to the residual """ def __init__(self, m=5, beta=1.0): """ Args: m (int): number of previous iterates kept in the acceleration memory beta (float): mixing (relaxation) parameter applied to the residual Returns: None """ self.m = max(1, int(m)) self.beta = float(beta) self._scale = None self._X = [] self._F = []
[docs] def update(self, x, gx): """ Propose the next iterate from the current iterate and map value. Args: x (array_like): current iterate of the flattened outer-loop variables gx (array_like): value of the fixed-point map G(x) implied by the model at the current iterate Returns: x_next (Numpy array): proposed next iterate, in the same (unscaled) units as ``x`` """ x = np.asarray(x, dtype=float) gx = np.asarray(gx, dtype=float) if self._scale is None: ref = np.abs(x) self._scale = np.maximum(ref, 1e-3 * (ref.max() or 1.0)) x_s = x / self._scale f = gx / self._scale - x_s self._X.append(x_s) self._F.append(f) if len(self._F) == 1: return (x_s + self.beta * f) * self._scale m = min(self.m, len(self._F) - 1) dF = np.column_stack( [self._F[-i] - self._F[-i - 1] for i in range(1, m + 1)] ) dX = np.column_stack( [self._X[-i] - self._X[-i - 1] for i in range(1, m + 1)] ) gamma, *_ = np.linalg.lstsq(dF, f, rcond=None) x_next = x_s + self.beta * f - (dX + self.beta * dF) @ gamma if len(self._F) > self.m + 1: self._X.pop(0) self._F.pop(0) return x_next * self._scale
[docs] def reset(self): """ Clear the stored iterate and residual history so the next step restarts the acceleration from scratch (used by ``run_TPI``'s safety net when an accelerated step diverges). The fixed per-element scale is kept. Returns: None """ self._X = [] self._F = []
[docs] def pack_outer_vars(blocks, T): """ Stack the first ``T`` periods of each (current, implied) pair of outer-loop arrays into the flat vectors the update rule works on. Args: blocks (list): (current, implied) pairs of Numpy arrays for the outer-loop variables, e.g. [(r_p, r_p_new), (r, rnew), ...] T (int): number of transition-path periods to include Returns: (tuple): stacked outer-loop vectors: * x (Numpy array): current iterate * gx (Numpy array): implied fixed-point map value G(x) """ x = np.concatenate([cur[:T].ravel() for cur, _ in blocks]) gx = np.concatenate([imp[:T].ravel() for _, imp in blocks]) return x, gx
[docs] def unpack_outer_vars(x_next, blocks, T): """ Write a stacked next iterate back into the first ``T`` periods of each current outer-loop array, in place (the inverse of ``pack_outer_vars``). Args: x_next (Numpy array): stacked next iterate from the update rule blocks (list): (current, implied) pairs of Numpy arrays, in the same order passed to ``pack_outer_vars`` T (int): number of transition-path periods in the stack Returns: None """ off = 0 for cur, _ in blocks: seg = cur[:T] cur[:T] = x_next[off : off + seg.size].reshape(seg.shape) off += seg.size
[docs] def make_outer_updater(method, p): """ Create the outer-loop updater selected by ``p.TPI_outer_method``. Args: method (str or None): outer-loop update rule, either "picard" or "anderson" (None defaults to "picard") p (OG-Core Specifications object): model parameters Returns: updater (AndersonAccelerator or None): accelerator instance for "anderson", or None for "picard" -- the model's historical damped functional iteration, which ``run_TPI`` handles with its native update Raises: ValueError: if ``method`` is not a recognized update rule """ method = (method or "picard").lower() if method == "picard": return None if method == "anderson": return AndersonAccelerator( m=int(getattr(p, "TPI_anderson_m", 5)), beta=float(getattr(p, "TPI_anderson_beta", 1.0)), ) raise ValueError(f"unknown TPI_outer_method: {method!r}")
def _selftest(): """ Validate the accelerator math on a linear contraction fixed point, independent of OG-Core: Anderson should converge and beat plain Picard (functional) iteration. Returns: out (dict): iterations to convergence for the damped Picard and Anderson update rules """ A = np.array([[0.6, 0.2, 0.0], [0.1, 0.5, 0.2], [0.0, 0.3, 0.7]]) b = np.array([1.0, -2.0, 3.0]) def gmap(x): # contraction; fixed point solves (I - A) x = b return A @ x + b def run(updater, tol=1e-12, maxit=500): x = np.zeros(3) for k in range(1, maxit + 1): g = gmap(x) if np.max(np.abs(g - x)) < tol: return k x = ( updater.update(x, g) if updater is not None else 0.5 * g + 0.5 * x ) return maxit out = { "picard_iters": run(None), "anderson_iters": run(AndersonAccelerator(m=3, beta=1.0)), } assert out["anderson_iters"] < out["picard_iters"], out return out if __name__ == "__main__": print(_selftest())