Source code for statsmodels.tsa.vector_ar.svar_model

"""
Vector Autoregression (VAR) processes

References
----------
Lütkepohl (2005) New Introduction to Multiple Time Series Analysis
"""

from statsmodels.compat.pandas import deprecate_kwarg

import numpy as np
import numpy.linalg as npl
from numpy.linalg import slogdet

from statsmodels.tools.numdiff import approx_fprime, approx_hess
import statsmodels.tsa.base.tsa_model as tsbase
from statsmodels.tsa.vector_ar import util
from statsmodels.tsa.vector_ar.irf import IRAnalysis
from statsmodels.tsa.vector_ar.var_model import VARProcess, VARResults


def svar_ckerr(svar_type, A, B):
    if A is None and (svar_type == "A" or svar_type == "AB"):
        raise ValueError("SVAR of type A or AB but A array not given.")
    if B is None and (svar_type == "B" or svar_type == "AB"):

        raise ValueError("SVAR of type B or AB but B array not given.")


[docs] class SVAR(tsbase.TimeSeriesModel): r""" Fit VAR and then estimate structural components of A and B, defined: .. math:: Ay_t = A_1 y_{t-1} + \ldots + A_p y_{t-p} + B \varepsilon_t Parameters ---------- endog : array_like 2-d endogenous response variable. The independent variable. svar_type : str "A" - estimate structural parameters of A matrix, B assumed = I "B" - estimate structural parameters of B matrix, A assumed = I "AB" - estimate structural parameters indicated in both A and B matrix dates : array_like must match number of rows of endog freq : str, optional The frequency of the time-series. A Pandas offset or 'B', 'D', 'W', 'M', 'A', or 'Q'. This is optional if dates are given. A : array_like neqs x neqs with unknown parameters marked with 'E' for estimate B : array_like neqs x neqs with unknown parameters marked with 'E' for estimate missing : str Available options are 'none', 'drop', and 'raise'. If 'none', no nan checking is done. If 'drop', any observations with nans are dropped. If 'raise', an error is raised. Default is 'none'. References ---------- Hamilton (1994) Time Series Analysis """ def __init__( self, endog, svar_type, dates=None, freq=None, A=None, B=None, missing="none" ): super().__init__(endog, None, dates, freq, missing=missing) # (self.endog, self.names, # self.dates) = data_util.interpret_data(endog, names, dates) self.neqs = self.endog.shape[1] types = ["A", "B", "AB"] if svar_type not in types: raise ValueError("SVAR type not recognized, must be in " + str(types)) self.svar_type = svar_type svar_ckerr(svar_type, A, B) self.A_original = A self.B_original = B # Set by fit()/_estimate_svar(), once the lag order is known self.nobs = None self.sigma_u = None # initialize A, B as I if not given # Initialize SVAR masks if A is None: A = np.identity(self.neqs) self.A_mask = A_mask = np.zeros(A.shape, dtype=bool) else: A = A.astype("U") A_mask = np.logical_or(A == "E", A == "e") self.A_mask = A_mask if B is None: B = np.identity(self.neqs) self.B_mask = B_mask = np.zeros(B.shape, dtype=bool) else: B = B.astype("U") B_mask = np.logical_or(B == "E", B == "e") self.B_mask = B_mask # convert A and B to numeric # TODO: change this when masked support is better or with formula # integration Anum = np.zeros(A.shape, dtype=float) Anum[~A_mask] = A[~A_mask] Anum[A_mask] = np.nan self.A = Anum Bnum = np.zeros(B.shape, dtype=float) Bnum[~B_mask] = B[~B_mask] Bnum[B_mask] = np.nan self.B = Bnum # LikelihoodModel.__init__(self, endog) # super().__init__(endog)
[docs] def fit( self, A_guess=None, B_guess=None, maxlags=None, method="ols", ic=None, trend="c", verbose=False, s_method="mle", solver="bfgs", override=False, maxiter=500, maxfun=500, ): """ Fit the SVAR model and solve for structural parameters Parameters ---------- A_guess : array_like, optional A vector of starting values for all parameters to be estimated in A. B_guess : array_like, optional A vector of starting values for all parameters to be estimated in B. maxlags : int Maximum number of lags to check for order selection, defaults to 12 * (nobs/100.)**(1./4), see select_order function method : {'ols'} Estimation method to use ic : {'aic', 'fpe', 'hqic', 'bic', None} Information criterion to use for VAR order selection. aic : Akaike fpe : Final prediction error hqic : Hannan-Quinn bic : Bayesian a.k.a. Schwarz verbose : bool, default False Print order selection output to the screen trend : str {"c", "ct", "ctt", "n"} "c" - add constant "ct" - constant and trend "ctt" - constant, linear and quadratic trend "n" - co constant, no trend Note that these are prepended to the columns of the dataset. s_method : {'mle'} Estimation method for structural parameters solver : {'nm', 'newton', 'bfgs', 'cg', 'ncg', 'powell'} Solution method See statsmodels.base for details override : bool, default False If True, returns estimates of A and B without checking order or rank condition maxiter : int, default 500 Number of iterations to perform in solution method maxfun : int Number of function evaluations to perform Returns ------- est : SVARResults Notes ----- Lütkepohl pp. 146-153 Hamilton pp. 324-336 """ lags = maxlags if ic is not None: selections = self.select_order(maxlags=maxlags, verbose=verbose) if ic not in selections: raise ValueError( f"{ic} not recognized, must be among {sorted(selections)}" ) lags = selections[ic] if verbose: print(f"Using {lags:d} based on {ic} criterion") elif lags is None: lags = 1 self.nobs = len(self.endog) - lags # initialize starting parameters start_params = self._get_init_params(A_guess, B_guess) return self._estimate_svar( start_params, lags, trend=trend, solver=solver, override=override, maxiter=maxiter, maxfun=maxfun, )
def _get_init_params(self, A_guess, B_guess): """Returns either the given starting values or .1 if none are given""" var_type = self.svar_type.lower() n_masked_a = self.A_mask.sum() if var_type in ["ab", "a"]: if A_guess is None: A_guess = np.array([0.1] * n_masked_a) elif len(A_guess) != n_masked_a: msg = "len(A_guess) = %s, there are %s parameters in A" raise ValueError(msg % (len(A_guess), n_masked_a)) else: A_guess = [] n_masked_b = self.B_mask.sum() if var_type in ["ab", "b"]: if B_guess is None: B_guess = np.array([0.1] * n_masked_b) elif len(B_guess) != n_masked_b: msg = "len(B_guess) = %s, there are %s parameters in B" raise ValueError(msg % (len(B_guess), n_masked_b)) else: B_guess = [] return np.r_[A_guess, B_guess] def _estimate_svar( self, start_params, lags, maxiter, maxfun, trend="c", solver="nm", override=False, ): """ Estimate the reduced-form VAR and then solve for structural params Parameters ---------- start_params : array_like Starting values for the parameters to be estimated. lags : int Number of lags in the VAR. maxiter : int Number of iterations to perform in solution method. maxfun : int Number of function evaluations to perform. trend : {str, None} As per above solver : str Solution method override : bool, default False If True, returns estimates of A and B without checking order or rank condition """ k_trend = util.get_trendorder(trend) y = self.endog z = util.get_var_endog(y, lags, trend=trend, has_constant="raise") y_sample = y[lags:] # Lutkepohl p75, about 5x faster than stated formula var_params = np.linalg.lstsq(z, y_sample, rcond=-1)[0] resid = y_sample - np.dot(z, var_params) # Unbiased estimate of covariance matrix $\Sigma_u$ of the white noise # process $u$ # equivalent definition # .. math:: \frac{1}{T - Kp - 1} Y^\prime (I_T - Z (Z^\prime Z)^{-1} # Z^\prime) Y # Ref: Lutkepohl p.75 # df_resid right now is T - Kp - 1, which is a suggested correction avobs = len(y_sample) df_resid = avobs - (self.neqs * lags + k_trend) sse = np.dot(resid.T, resid) # TODO: should give users the option to use a dof correction or not omega = sse / df_resid self.sigma_u = omega A, B = self._solve_AB( start_params, override=override, solver=solver, maxiter=maxiter ) A_mask = self.A_mask B_mask = self.B_mask return SVARResults( y, z, var_params, omega, lags, names=self.endog_names, trend=trend, dates=self.data.dates, model=self, A=A, B=B, A_mask=A_mask, B_mask=B_mask, )
[docs] def loglike(self, params): """ Loglikelihood for SVAR model Parameters ---------- params : array_like The concatenated unknown parameters of the A and B matrices. Notes ----- This method assumes that the autoregressive parameters are first estimated, then likelihood with structural parameters is estimated """ # TODO: this does not look robust if A or B is None A = self.A B = self.B A_mask = self.A_mask B_mask = self.B_mask A_len = len(A[A_mask]) B_len = len(B[B_mask]) if A is not None: A[A_mask] = params[:A_len] if B is not None: B[B_mask] = params[A_len : A_len + B_len] nobs = self.nobs neqs = self.neqs sigma_u = self.sigma_u W = np.dot(npl.inv(B), A) trc_in = np.dot(np.dot(W.T, W), sigma_u) sign, b_logdet = slogdet(B**2) # numpy 1.4 compat b_slogdet = sign * b_logdet likl = ( -nobs / 2.0 * ( neqs * np.log(2 * np.pi) - np.log(npl.det(A) ** 2) + b_slogdet + np.trace(trc_in) ) ) return likl
[docs] def score(self, AB_mask): """ Return the gradient of the loglike at AB_mask Parameters ---------- AB_mask : array_like Concatenated unknown values of the A and B matrices. Notes ----- Return numerical gradient """ loglike = self.loglike if AB_mask.ndim > 1: AB_mask = AB_mask.ravel() grad = approx_fprime(AB_mask, loglike, epsilon=1e-8) # workaround shape of grad if only one parameter #9302 if AB_mask.size == 1 and grad.ndim == 2: grad = grad.ravel() return grad
[docs] def hessian(self, AB_mask): """ Returns numerical hessian Parameters ---------- AB_mask : array_like Concatenated unknown values of the A and B matrices. """ loglike = self.loglike if AB_mask.ndim > 1: AB_mask = AB_mask.ravel() return approx_hess(AB_mask, loglike)
def _solve_AB(self, start_params, maxiter, override=False, solver="bfgs"): """ Solves for MLE estimate of structural parameters Parameters ---------- start_params : array_like Starting values for the parameters to be estimated. maxiter : int, optional The maximum number of iterations. Default is 500. override : bool, default False If True, returns estimates of A and B without checking order or rank condition solver : str or None, optional Solver to be used. The default is 'nm' (Nelder-Mead). Other choices are 'bfgs', 'newton' (Newton-Raphson), 'cg' conjugate, 'ncg' (non-conjugate gradient), and 'powell'. Returns ------- A_solve : ndarray ML solution for the A matrix. B_solve : ndarray ML solution for the B matrix. """ # TODO: this could stand a refactor A_mask = self.A_mask B_mask = self.B_mask A = self.A B = self.B A_len = len(A[A_mask]) A[A_mask] = start_params[:A_len] B[B_mask] = start_params[A_len:] if not override: J = self._compute_J(A, B) self.check_order(J) self.check_rank(J) else: # TODO: change to a warning? print("Order/rank conditions have not been checked") if solver == "bfgs": kwargs = {"gtol": 1e-5} else: kwargs = {} retvals = ( super() .fit( start_params=start_params, method=solver, maxiter=maxiter, disp=False, **kwargs, ) .params ) if retvals.ndim > 1: retvals = retvals.ravel() A[A_mask] = retvals[:A_len] B[B_mask] = retvals[A_len:] return A, B def _compute_J(self, A_solve, B_solve): # first compute appropriate duplication matrix # taken from Magnus and Neudecker (1980), # "The Elimination Matrix: Some Lemmas and Applications # the creation of the D_n matrix follows MN (1980) directly, # while the rest follows Hamilton (1994) neqs = self.neqs sigma_u = self.sigma_u A_mask = self.A_mask B_mask = self.B_mask # first generate duplication matrix, see MN (1980) for notation D_nT = np.zeros([int((1.0 / 2) * (neqs) * (neqs + 1)), neqs**2]) for j in range(neqs): i = j while j <= i < neqs: u = np.zeros([int((1.0 / 2) * neqs * (neqs + 1)), 1]) u[int(j * neqs + (i + 1) - (1.0 / 2) * (j + 1) * j - 1)] = 1 Tij = np.zeros([neqs, neqs]) Tij[i, j] = 1 Tij[j, i] = 1 D_nT = D_nT + np.dot(u, (Tij.ravel("F")[:, None]).T) i = i + 1 D_n = D_nT.T D_pl = npl.pinv(D_n) # generate S_B S_B = np.zeros((neqs**2, len(A_solve[A_mask]))) S_D = np.zeros((neqs**2, len(B_solve[B_mask]))) j = 0 j_d = 0 if len(A_solve[A_mask]) != 0: A_vec = np.ravel(A_mask, order="F") for k in range(neqs**2): if A_vec[k]: S_B[k, j] = -1 j += 1 if len(B_solve[B_mask]) != 0: B_vec = np.ravel(B_mask, order="F") for k in range(neqs**2): if B_vec[k]: S_D[k, j_d] = 1 j_d += 1 # now compute J invA = npl.inv(A_solve) J_p1i = np.dot(np.dot(D_pl, np.kron(sigma_u, invA)), S_B) J_p1 = -2.0 * J_p1i J_p2 = np.dot(np.dot(D_pl, np.kron(invA, invA)), S_D) J = np.append(J_p1, J_p2, axis=1) return J
[docs] def check_order(self, J): if np.size(J, axis=0) < np.size(J, axis=1): raise ValueError("Order condition not met: solution may not be unique")
[docs] def check_rank(self, J): rank = np.linalg.matrix_rank(J) if rank < np.size(J, axis=1): raise ValueError("Rank condition not met: solution may not be unique.")
[docs] class SVARProcess(VARProcess): """ Class represents a known SVAR(p) process Parameters ---------- coefs : ndarray (p x k x k) intercept : ndarray (length k) sigma_u : ndarray (k x k) A_solve : ndarray neqs x neqs np.ndarray with unknown parameters marked with 'E' B_solve : ndarray neqs x neqs np.ndarray with unknown parameters marked with 'E' names : sequence (length k) """ def __init__(self, coefs, intercept, sigma_u, A_solve, B_solve, names=None): self.k_ar = len(coefs) self.neqs = coefs.shape[1] self.coefs = coefs self.intercept = intercept self.sigma_u = sigma_u self.A_solve = A_solve self.B_solve = B_solve self.names = names
[docs] def orth_ma_rep(self, maxn=10, P=None): """Unavailable for SVAR""" raise NotImplementedError
[docs] def svar_ma_rep(self, maxn=10, P=None): """ Compute Structural MA coefficient matrices using MLE of A, B Parameters ---------- maxn : int, optional Number of MA matrices to compute. P : ndarray, optional Solved A^{-1}B coefficient matrix used to orthogonalize the MA representation. If not provided, it is computed from the estimated A and B matrices. Returns ------- ndarray The structural MA coefficient matrices. """ if P is None: A_solve = self.A_solve B_solve = self.B_solve P = np.dot(npl.inv(A_solve), B_solve) ma_mats = self.ma_rep(maxn=maxn) return np.array([np.dot(coefs, P) for coefs in ma_mats])
[docs] class SVARResults(SVARProcess, VARResults): """ Estimate VAR(p) process with fixed number of lags Parameters ---------- endog : ndarray endog_lagged : ndarray params : ndarray sigma_u : ndarray lag_order : int A : neqs x neqs np.ndarray with unknown parameters marked with 'E' B : neqs x neqs np.ndarray with unknown parameters marked with 'E' A_mask : neqs x neqs mask array with known parameters masked B_mask : neqs x neqs mask array with known parameters masked model : VAR model instance trend : str {'n', 'c', 'ct'} names : array_like List of names of the endogenous variables in order of appearance in `endog`. dates Attributes ---------- aic bic bse coefs : ndarray (p x K x K) Estimated A_i matrices, A_i = coefs[i-1] cov_params dates detomega df_model : int df_resid : int endog endog_lagged fittedvalues fpe intercept info_criteria k_ar : int Order of VAR process k_trend : int llf model names : list variables names neqs : int Number of variables (equations) nobs : int n_totobs : int params : ndarray (Kp + 1) x K A_i matrices and intercept in stacked form [int A_1 ... A_p] pvalues resid sigma_u : ndarray (K x K) Estimate of white noise process variance Var[u_t] sigma_u_mle stderr trendorder tvalues """ _model_type = "SVAR" def __init__( self, endog, endog_lagged, params, sigma_u, lag_order, A=None, B=None, A_mask=None, B_mask=None, model=None, trend="c", names=None, dates=None, ): self.model = model self.endog = endog self.endog_lagged = endog_lagged self.dates = dates self.n_totobs, self.neqs = self.endog.shape self.nobs = self.n_totobs - lag_order k_trend = util.get_trendorder(trend) if k_trend > 0: # make this the polynomial trend order trendorder = k_trend - 1 else: trendorder = None self.k_trend = k_trend self.k_exog = k_trend # now (0.9) required by VARProcess self.k_exog_user = 0 self.trendorder = trendorder self.exog_names = util.make_lag_names(names, lag_order, k_trend) self.params = params self.sigma_u = sigma_u # Each matrix needs to be transposed reshaped = self.params[self.k_trend :] reshaped = reshaped.reshape((lag_order, self.neqs, self.neqs)) # Need to transpose each coefficient matrix intercept = self.params[0] coefs = reshaped.swapaxes(1, 2).copy() # SVAR components # TODO: if you define these here, you do not also have to define # them in SVAR process, but I left them for now -ss self.A = A self.B = B self.A_mask = A_mask self.B_mask = B_mask super().__init__(coefs, intercept, sigma_u, A, B, names=names)
[docs] def irf(self, periods=10, var_order=None): """ Analyze structural impulse responses to shocks in system Parameters ---------- periods : int var_order : sequence Alternate variable order for Cholesky decomposition Returns ------- irf : IRAnalysis """ A = self.A B = self.B P = np.dot(npl.inv(A), B) return IRAnalysis(self, P=P, periods=periods, svar=True)
[docs] @deprecate_kwarg("seed", "rng") def sirf_errband_mc( self, orth=False, repl=1000, steps=10, signif=0.05, rng=None, burn=100, cum=False, ): """ Compute Monte Carlo integrated error bands assuming normally distributed for impulse response functions Parameters ---------- orth : bool, default False Compute orthogonalized impulse response error bands repl : int number of Monte Carlo replications to perform steps : int, default 10 number of impulse response periods signif : float (0 < signif <1) Significance level for error bars, defaults to 95% CI rng : {None, int, array_like[int], numpy.random.Generator, numpy.random.RandomState}, optional np.random seed for replications seed : {None, int, array_like[int], numpy.random.Generator, numpy.random.RandomState}, optional .. deprecated:: 0.15 seed has been deprecated. In-line with SPEC-007, use rng for passing a random number generator or seed. burn : int number of initial observations to discard for simulation cum : bool, default False produce cumulative irf error bands Returns ------- Tuple of lower and upper arrays of ma_rep monte carlo standard errors Notes ----- Lütkepohl (2005) Appendix D """ neqs = self.neqs k_ar = self.k_ar coefs = self.coefs sigma_u = self.sigma_u intercept = self.intercept nobs = self.nobs ma_coll = np.zeros((repl, steps + 1, neqs, neqs)) A = self.A B = self.B A_mask = self.A_mask B_mask = self.B_mask A_pass = self.model.A_original B_pass = self.model.B_original s_type = self.model.svar_type g_list = [] def agg(impulses): if cum: return impulses.cumsum(axis=0) return impulses opt_A = A[A_mask] opt_B = B[B_mask] for i in range(repl): # discard first hundred to correct for starting bias sim = util.varsim(coefs, intercept, sigma_u, rng=rng, steps=nobs + burn) sim = sim[burn:] smod = SVAR(sim, svar_type=s_type, A=A_pass, B=B_pass) if i == 10: # Use first 10 to update starting val for remainder of fits mean_AB = np.mean(g_list, axis=0) split = len(A[A_mask]) opt_A = mean_AB[:split] opt_B = mean_AB[split:] sres = smod.fit(maxlags=k_ar, A_guess=opt_A, B_guess=opt_B) if i < 10: # save estimates for starting val if in first 10 g_list.append( np.append(sres.A[A_mask].tolist(), sres.B[B_mask].tolist()) ) ma_coll[i] = agg(sres.svar_ma_rep(maxn=steps)) ma_sort = np.sort(ma_coll, axis=0) # sort to get quantiles index = ( int(round(signif / 2 * repl) - 1), int(round((1 - signif / 2) * repl) - 1), ) lower = ma_sort[index[0], :, :, :] upper = ma_sort[index[1], :, :, :] return lower, upper