Source code for statsmodels.tsa.vector_ar.local_proj

"""
Local Projections (LP) estimator for impulse response functions.

Reference
---------
Jordà, Ò. (2005). Estimation and Inference of Impulse Responses by Local
Projections. *American Economic Review*, 95(1), 161-182.
"""

from __future__ import annotations

import numpy as np
import pandas as pd
from scipy import stats

from statsmodels.graphics.utils import _import_mpl
from statsmodels.regression.linear_model import OLS
from statsmodels.tools.validation import array_like, int_like, string_like

__all__ = ["LocalProjections"]


def _hac_bandwidth(h, nw_lags=None):
    """Return Newey-West bandwidth for an h-step-ahead LP regression."""
    if nw_lags is not None:
        return nw_lags
    # At horizon h the LP residual is an MA(h) by construction; use at
    # least h lags.  Fall back to a small positive value at h=0.
    return max(h, int(np.ceil(4.0 * (max(h, 1) / 100.0) ** (2.0 / 9.0))))


def _nw_cov(X, resid, nlags):
    """Newey-West HAC sandwich covariance (X'X)^{-1} S (X'X)^{-1} / T.

    Parameters
    ----------
    X : ndarray, shape (T, k)
    resid : ndarray, shape (T,)
    nlags : int

    Returns
    -------
    V : ndarray, shape (k, k)
    """
    T, k = X.shape
    scores = X * resid[:, None]           # (T, k)  score contributions
    S = scores.T @ scores / T             # gamma_0
    for lag in range(1, nlags + 1):
        w = 1.0 - lag / (nlags + 1.0)    # Bartlett kernel
        gamma = scores[lag:].T @ scores[:-lag] / T
        S += w * (gamma + gamma.T)
    XtX_inv = np.linalg.inv(X.T @ X / T)
    return XtX_inv @ S @ XtX_inv / T


[docs] class LocalProjectionsResults: """ Results from a fitted :class:`LocalProjections` model. Attributes ---------- irfs : ndarray, shape (H+1, n_endog, n_shock) Estimated impulse responses. ``irfs[h, i, j]`` is the response of variable ``i`` to shock ``j`` at horizon ``h``. stderr : ndarray, shape (H+1, n_endog, n_shock) Newey-West standard errors for each IRF coefficient. model : LocalProjections The model instance. nobs : int Number of observations used (after trimming for lags and max horizon). """ def __init__(self, model, irfs, stderr): self.model = model self.irfs = irfs self.stderr = stderr self.nobs = model._nobs @property def horizons(self): return self.model.horizons
[docs] def conf_int(self, alpha=0.05): """Pointwise confidence intervals for all impulse responses. Parameters ---------- alpha : float Significance level (default 0.05 gives 95 % intervals). Returns ------- ci : ndarray, shape (H+1, n_endog, n_shock, 2) ``ci[h, i, j, 0]`` = lower bound, ``ci[h, i, j, 1]`` = upper bound. """ z = stats.norm.ppf(1.0 - alpha / 2.0) lower = self.irfs - z * self.stderr upper = self.irfs + z * self.stderr return np.stack([lower, upper], axis=-1)
[docs] def cumulative_effects(self): """Cumulative sum of IRFs across horizons. Returns ------- ndarray, shape (H+1, n_endog, n_shock) """ return np.cumsum(self.irfs, axis=0)
[docs] def plot_irfs(self, impulse=None, response=None, alpha=0.1, figsize=None, n_cols=None): """Plot impulse response functions with confidence bands. Parameters ---------- impulse : int or str or None Index or name of the shock variable. Plots all shocks if None. response : int or str or None Index or name of the response variable. Plots all responses if None. alpha : float Significance level for confidence bands. figsize : tuple or None n_cols : int or None Returns ------- fig : matplotlib.figure.Figure """ plt = _import_mpl() var_names = self.model.endog_names shock_names = self.model.shock_names H = self.horizons ci = self.conf_int(alpha=alpha) x = np.arange(H + 1) imp_idx = (list(range(len(shock_names))) if impulse is None else [shock_names.index(impulse) if isinstance(impulse, str) else impulse]) resp_idx = (list(range(len(var_names))) if response is None else [var_names.index(response) if isinstance(response, str) else response]) n_plots = len(imp_idx) * len(resp_idx) n_cols = n_cols or min(3, n_plots) n_rows = int(np.ceil(n_plots / n_cols)) fig, axes = plt.subplots( n_rows, n_cols, figsize=figsize or (4 * n_cols, 3 * n_rows), squeeze=False, ) plot_num = 0 for j in imp_idx: for i in resp_idx: ax = axes[plot_num // n_cols, plot_num % n_cols] ax.plot(x, self.irfs[:, i, j], color="steelblue", lw=1.5) ax.fill_between( x, ci[:, i, j, 0], ci[:, i, j, 1], alpha=0.25, color="steelblue", ) ax.axhline(0, color="black", lw=0.8, ls="--") ax.set_title(f"{shock_names[j]}{var_names[i]}") ax.set_xlabel("Horizon") plot_num += 1 for k in range(plot_num, n_rows * n_cols): axes[k // n_cols, k % n_cols].set_visible(False) fig.tight_layout() return fig
def __repr__(self): return ( f"LocalProjectionsResults(" f"n_endog={self.irfs.shape[1]}, " f"n_shock={self.irfs.shape[2]}, " f"horizons={self.horizons}, " f"nobs={self.nobs})" )
[docs] class LocalProjections: r""" Local Projections estimator for impulse response functions. For each horizon :math:`h = 0, 1, \ldots, H`, the impulse response is estimated by running a separate OLS regression .. math:: y_{t+h} = \alpha_h + \boldsymbol{\beta}_h \mathbf{z}_t + \boldsymbol{\Gamma}_h \mathbf{x}_t + \varepsilon_{t+h}, where :math:`\mathbf{z}_t` are the shock variables and :math:`\mathbf{x}_t` contains lagged controls. Standard errors are Newey-West HAC-corrected with a bandwidth of at least :math:`h` lags to account for the MA(:math:`h`) serial correlation in the residuals. Parameters ---------- endog : array_like, shape (T, n) or (T,) Endogenous variables. A 1-D input is treated as a single variable. shock_idx : int or list[int], optional Column index/indices within *endog* whose contemporaneous value enters as the shock. Defaults to ``0`` (the first variable). lags : int, optional Number of lagged values of *all* endog variables to include as controls. Defaults to ``1``. horizons : int, optional Maximum IRF horizon :math:`H`. Regressions are run for :math:`h = 0, 1, \ldots, H`. Defaults to ``12``. exog : array_like, shape (T, k) or None, optional Additional exogenous controls (e.g. time dummies, external instruments). Do **not** include a constant; it is added automatically. trend : {"n", "c", "ct"}, optional Deterministic trend: ``"n"`` none, ``"c"`` constant (default), ``"ct"`` constant plus linear trend. nw_lags : int or None, optional Override the automatic Newey-West bandwidth. When *None* (default) the bandwidth at horizon :math:`h` is ``max(h, ceil(4*(max(h,1)/100)^{2/9}))``. See Also -------- statsmodels.tsa.vector_ar.var_model.VAR : Vector autoregression model, whose Cholesky-orthogonalized impulse responses are the VAR-based analogue of the estimator here. statsmodels.tsa.vector_ar.svar_model.SVAR : Structural vector autoregression model, for identification schemes other than a simple recursive ordering. statsmodels.tsa.vector_ar.irf.IRAnalysis : Impulse response object returned by :meth:`VAR.fit`, sharing the same "shock ordered first" identification assumption. Notes ----- Identification follows a recursive (Cholesky-style) scheme in which the variable(s) named by `shock_idx` are assumed to be ordered first, i.e. contemporaneously exogenous to the remaining variables in `endog`: only the *lagged* values of the other variables enter each regression, never their current-period values. This is the same "shock ordered first" assumption underlying Cholesky-orthogonalized VAR impulse responses (see :class:`~statsmodels.tsa.vector_ar.irf.IRAnalysis`), and the fitted coefficient on `shock_idx` only has a structural impulse-response interpretation to the extent that assumption is credible for the data at hand. When `shock_idx` names more than one column, those columns enter the regression together as ordinary simultaneous regressors with no orthogonalization among themselves; their coefficients are only separately identified as shocks if those variables are themselves mutually contemporaneously exogenous. References ---------- Jordà, Ò. (2005). Estimation and Inference of Impulse Responses by Local Projections. *American Economic Review*, 95(1), 161–182. Examples -------- >>> import numpy as np >>> from statsmodels.tsa.api import LocalProjections >>> rng = np.random.default_rng(0) >>> T, n = 200, 2 >>> e = rng.standard_normal((T, n)) >>> y = np.cumsum(e, axis=0) >>> lp = LocalProjections(y, shock_idx=0, lags=2, horizons=8) >>> res = lp.fit() >>> res.irfs.shape (9, 2, 1) """ def __init__( self, endog, shock_idx=0, lags=1, horizons=12, exog=None, trend="c", nw_lags=None, ): # Capture column names before array_like strips them. _endog_col_names = (list(endog.columns) if isinstance(endog, pd.DataFrame) else None) endog = array_like(endog, "endog", ndim=2, optional=False, dtype=float) lags = int_like(lags, "lags", optional=False) horizons = int_like(horizons, "horizons", optional=False) exog = array_like(exog, "exog", ndim=2, optional=True, dtype=float) trend = string_like(trend, "trend", optional=False, options=("n", "c", "ct")) nw_lags = int_like(nw_lags, "nw_lags", optional=True) self.endog = endog t, n = self.endog.shape if isinstance(shock_idx, (int, np.integer)): shock_idx_list = [int(shock_idx)] else: shock_idx_list = [int(val) for val in shock_idx] for idx in shock_idx_list: if not (0 <= idx < n): raise ValueError( f"shock_idx={idx} out of range for endog with {n} columns." ) self.shock_idx = shock_idx_list if lags < 0: raise ValueError("lags must be non-negative.") if horizons < 0: raise ValueError("horizons must be non-negative.") self.lags = int(lags) self.horizons = int(horizons) self.trend = trend self.nw_lags = nw_lags if exog is not None: exog = np.asarray(exog, dtype=float) if exog.shape[0] != t: raise ValueError( "exog must have the same number of rows as endog." ) self.exog = exog # Variable names for output / plotting. if _endog_col_names is not None: self.endog_names = _endog_col_names else: self.endog_names = [f"y{i}" for i in range(n)] self.shock_names = [self.endog_names[j] for j in shock_idx_list] # Usable obs: first `lags` rows consumed by lag construction; # last `horizons` rows have no h-step-ahead LHS. self._nobs = t - lags - horizons # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _build_regressors(self, t_start, t_end): """Construct the RHS design matrix for t = t_start ... t_end-1. Column order ------------ [shock_0, ..., shock_s | lag1_y0, ..., lag1_yn, lag2_y0, ... | exog | trend terms] Returns ------- X : ndarray, shape (n_obs, k) shock_cols : list of int Column indices of the shock variables. """ endog = self.endog parts = [] # Contemporaneous shocks (columns come first so their indices are # always 0, 1, ..., n_shock-1). parts.append(endog[t_start:t_end, self.shock_idx]) shock_cols = list(range(len(self.shock_idx))) # Lagged controls. for lag in range(1, self.lags + 1): parts.append(endog[t_start - lag : t_end - lag, :]) # Exogenous. if self.exog is not None: parts.append(self.exog[t_start:t_end]) # Deterministic terms. n_obs = t_end - t_start if self.trend in ("c", "ct"): parts.append(np.ones((n_obs, 1))) if self.trend == "ct": # Centered (mean zero) time index rather than the raw absolute # sample position: leaves the fitted shock coefficients and # fitted values unchanged (only the intercept absorbs the # shift) while keeping the trend column well-conditioned # against the constant column for long series. idx = np.arange(n_obs, dtype=float) idx -= idx.mean() parts.append(idx[:, None]) return np.concatenate(parts, axis=1), shock_cols # ------------------------------------------------------------------ # Fit # ------------------------------------------------------------------
[docs] def fit(self): """Estimate LP-IRFs by running H+1 OLS regressions. Returns ------- LocalProjectionsResults """ H = self.horizons lags = self.lags endog = self.endog T, n = endog.shape n_shock = len(self.shock_idx) t_start = lags t_end = T - H # exclusive upper bound if t_end <= t_start: raise ValueError( f"Not enough observations: need T > lags + horizons = " f"{lags + H}, got T = {T}." ) X, shock_cols = self._build_regressors(t_start, t_end) irfs = np.zeros((H + 1, n, n_shock)) stderr = np.zeros((H + 1, n, n_shock)) for h in range(H + 1): Y_h = endog[t_start + h : t_end + h, :] # (n_obs, n) nlags = _hac_bandwidth(h, self.nw_lags) for i in range(n): res = OLS(Y_h[:, i], X).fit() V = _nw_cov(X, res.resid, nlags) for s_pos, s_col in enumerate(shock_cols): irfs[h, i, s_pos] = res.params[s_col] stderr[h, i, s_pos] = np.sqrt(max(V[s_col, s_col], 0.0)) return LocalProjectionsResults(self, irfs, stderr)