Model card — Structural identification (advanced)¶
long_run_svar · max_share_svar · proxy_svar · hetero_svar ·
nongaussian_svar · structural_fevd · historical_decomposition ·
narrative_svar · fry_pagan_svar · robust_svar_bounds
A structural VAR is a reduced-form VAR plus one identifying assumption that rotates the estimated residuals into economically meaningful shocks. The VAR/SVAR card covers the recursive (Cholesky) and sign-restricted schemes; this card covers two families that build on them.
Point-identification schemes (below) spend a different kind of outside information — a long-run neutrality, a variance-share objective, an external instrument, a documented variance regime, or the non-Gaussianity of the shocks themselves (a distributional assumption rather than an economic restriction). Each returns a point identification (no bands in this build): the estimand is one impact matrix or one structural column, and the honest uncertainty is a v2 bootstrap item flagged per method below. All five take a plain data matrix, estimate the reduced form internally, and are deterministic — no RNG, no rejection sampling.
Post-identification and prior-robust tools
(below) do not identify a new
scheme; they take an identification (any impact matrix A0, or a
sign-restricted set) and answer the questions that come after: how a shock splits
a variable's forecast-error variance (structural_fevd); how it drove each
historical observation (historical_decomposition); which single coherent draw
sits at the middle of a sign-restricted set (fry_pagan_svar); how the
identified set widens once the Haar-prior artifact is removed
(robust_svar_bounds); and how episode knowledge from the historical record
shrinks it (narrative_svar). These are the answers to the two honesty
critiques the sign-restriction section
raises — pointwise medians mix models, and the rotation prior never washes out.
Which one you reach for is a question about what you can defend, laid out in chapter 8 and the decision guide. The one-line map: long-run when theory speaks about permanent vs. transitory effects; max-share when you want the single shock that drives a target's business-cycle variance; proxy when you have a measured instrument for one shock; heteroskedasticity when you have documented variance regimes; non-Gaussianity when you distrust every economic restriction but the shocks are plausibly non-Gaussian; then the post-identification tools once a scheme is chosen.
long_run_svar — Blanchard-Quah long-run restrictions¶
What it estimates. Structural IRFs under the Blanchard-Quah (1989)
frequency-zero restriction: some shocks are constrained to have zero
cumulative (long-run) effect on some variables. The classic bivariate case —
output growth and unemployment — imposes that the "demand" shock has no
permanent effect on the level of output, leaving the "supply" shock as the only
source of the stochastic trend. Closed-form, the exact analog of R
vars::BQ (Pfaff 2008).
Assumptions. A correct reduced form; the long-run neutrality is economically true; and — the caveat the scheme is famous for — the VAR's largest roots are not too close to one. The long-run multiplier is \(C(1) = (I - A_1 - \cdots - A_p)^{-1}\), which blows up as persistence approaches a unit root, so small coefficient errors become large long-run-matrix errors (Faust-Leeper 1997). Check the VAR's stability before trusting a long-run scheme; prefer a VECM when cointegration is plausible.
When to use (and when not). Use when theory is silent about within-period timing but loud about the long run (supply/demand decompositions, permanent vs. transitory income). Do not use on highly persistent levels without checking the roots; do not read the impact matrix as the finding — the restriction lives at the infinite horizon, so the cumulative IRF is the object to read.
Key arguments and defaults (and why). lags, horizon, trend="c".
restrictions=None gives the classic recursive BQ (long-run matrix lower
triangular); pass a list of (variable, shock) long-run-zero pairs for a custom
pattern. normalize="long_run" (default) makes the long-run diagonal positive;
"impact" makes the impact diagonal positive instead — a sign convention, not a
different model.
How to read the output. impact (B), long_run (LR = C(1)·B, the cumulative
structural impact — check its imposed zeros), long_run_multiplier (C(1)),
irf [h][i][j], cumulative_irf (the level response for differenced
variables — the one to plot), and fevd. The demand shock's cumulative effect
on output should visibly decay to zero: that is the restriction, echoed back as a
built-in sanity check.
Failure modes. Near-unit roots make the long-run matrix unreliable (fragile, silent); reading the impact IRF instead of the cumulated one for differenced data; forgetting that "supply"/"demand" are labels you attach, not properties the math knows.
Validated against. An independent NumPy transcription of the documented
closed form (faer LU inverse + lower Cholesky vs. NumPy) — a cross-implementation
golden (long_run_svar.json,
long_run.rs). See the
validation matrix.
References. Blanchard & Quah (1989); Faust & Leeper (1997); Pfaff (2008,
vars).
import numpy as np, tsecon
# Blanchard-Quah bivariate: output growth (dy) and unemployment (u).
rng = np.random.default_rng(0)
T = 400
es = rng.standard_normal(T) # supply (permanent)
ed = rng.standard_normal(T) # demand (transitory)
dy = np.zeros(T); u = np.zeros(T)
for t in range(2, T):
dy[t] = 0.2 * dy[t - 1] + es[t] + 0.5 * ed[t] - 0.5 * ed[t - 1]
u[t] = 0.6 * u[t - 1] - 0.3 * es[t] + 0.7 * ed[t]
data = np.column_stack([dy, u])
bq = tsecon.long_run_svar(data, lags=4, horizon=20, trend="c")
lr = np.asarray(bq["long_run"])
print("long-run matrix LR (lower-triangular by construction):\n", np.round(lr, 4))
cum = np.asarray(bq["cumulative_irf"]) # [h][response][shock]
print("cumulative output response to the demand shock, h = 0, 4, 20:",
np.round(cum[[0, 4, 20], 0, 1], 6))
long-run matrix LR (lower-triangular by construction):
[[ 1.2008 0. ]
[-0.3017 1.6799]]
cumulative output response to the demand shock, h = 0, 4, 20: [ 3.75038e-01 -1.11681e-01 -1.10000e-05]
The upper-right entry of long_run is exactly zero — the imposed neutrality —
and output's cumulative response to the demand shock (0.375 on impact) decays
to \(-1.1\times10^{-5}\) by horizon 20: the level of output returns to baseline, as
the restriction requires.
max_share_svar — maximum forecast-error-variance-share shock¶
What it estimates. The single unit-variance structural shock whose share of a
target variable's forecast-error variance, accumulated over a horizon window
[h0, h1], is maximal — Uhlig's (2004) penalty-free eigenvalue variant, the
Francis-Owyang-Roush-DiCecio (2014) main-business-cycle shock, and (with a zero
impact) the Barsky-Sims (2011) news shock. Closed-form: the identified impact
direction is the leading eigenvector of a small symmetric PSD matrix built from
the orthogonalized MA coefficients. No rotation sampling.
Assumptions. A correct reduced form and a target/window that encode a real economic question ("the shock that drives medium-run output"). The identified shock is defined purely by the variance objective — it carries no economic label until you check its IRF signs or its correlation with an external series.
When to use (and when not). Use to extract a single dominant driver of a target's low- or business-cycle-frequency variance without committing to signs or an ordering — technology/news shocks, "the" financial shock. Do not use it as a general SVAR (it identifies one shock, not the whole B); do not over-interpret the label; watch that the leading eigenvalue is well separated from the rest (otherwise the max-share direction is only weakly pinned down).
Key arguments and defaults (and why). target=0 (the variable whose FEV is
maximized), h0/h1 (the accumulation window — e.g. 6..32 quarters for the
business cycle), horizon, lags, trend. weighting="window" (Uhlig/Francis;
maximizes the incremental windowed FEV — share_window is then an exact
accumulated-FEV fraction) or "cumulative" (Barsky-Sims window-mean cumulative
share). exclude_impact=True forces zero impact on the target (the Barsky-Sims
news shock). sign pins the identified sign ("cumsum"/"impact"/"none").
How to read the output. share_window (the maximand — the accumulated-FEV
fraction the identified shock achieves over the window), impact [k] (its
impact vector), irf [h][k] (the response of every variable to it),
fev_share [h] (its share of the target's total FEV at each horizon — lower
than share_window, because the objective targets the window's incremental
variance, not the total at any one horizon), q (the rotation weights), and
eigenvalues (ascending; the identified shock is the top eigenvector, and the
gap to the next eigenvalue is the identification margin).
Failure modes. A poorly separated leading eigenvalue (the max-share direction is nearly a tie); reading the FEV-maximizing shock as "the technology shock" without corroboration; choosing a window that does not match the frequency band you mean.
Validated against. An independent NumPy reference — numpy.linalg.lstsq for
the reduced form, numpy.linalg.cholesky for the orthogonalization, and a NumPy
eigensolver for the leading eigenvector
(max_share_svar.json,
max_share.rs).
References. Uhlig (2004); Barsky & Sims (2011); Francis, Owyang, Roush & DiCecio (2014).
import numpy as np, tsecon
rng = np.random.default_rng(3)
T = 500
eps = rng.standard_normal((T, 3))
B0 = np.array([[0.9, 0.6, 0.5],
[0.4, 0.9, 0.30],
[0.3, 0.25, 0.8]])
A1 = np.array([[0.4, 0.05, 0.0],
[0.1, 0.4, 0.05],
[0.0, 0.1, 0.45]])
y = np.zeros((T, 3))
for t in range(1, T):
y[t] = A1 @ y[t - 1] + B0 @ eps[t]
ms = tsecon.max_share_svar(y, lags=2, target=0, h0=6, h1=32, horizon=40,
weighting="window", sign="cumsum")
print("share_window (accumulated FEV of variable 0 over [6,32]):", round(ms["share_window"], 4))
print("impact vector:", np.round(np.asarray(ms["impact"]), 4))
print("target response h = 0, 4, 8:", np.round(np.asarray(ms["irf"])[[0, 4, 8], 0], 4))
# Barsky-Sims news shock: zero impact on the target, cumulative weighting
news = tsecon.max_share_svar(y, lags=2, target=0, h0=0, h1=40, horizon=40,
exclude_impact=True, weighting="cumulative")
print("news-shock impact on target (forced to zero):",
round(float(np.asarray(news["impact"])[0]), 6))
share_window (accumulated FEV of variable 0 over [6,32]): 0.9499
impact vector: [0.7025 0.9239 0.3703]
target response h = 0, 4, 8: [0.7025 0.0357 0.0028]
news-shock impact on target (forced to zero): 0.0
The identified shock explains 95% of variable 0's forecast-error variance
accumulated across the [6, 32] window — it is the business-cycle driver of
that variable in this synthetic system. Flipping exclude_impact=True re-poses
the problem as a news shock and drives the impact response to an exact zero.
proxy_svar — external-instrument identification (SVAR-IV)¶
What it estimates. One structural shock's impact column from a single external instrument (proxy) — the modern applied default for monetary and tax questions (Stock-Watson 2018; Mertens-Ravn 2013; Gertler-Karadi 2015). The covariance of the instrument with the reduced-form residuals pins the target shock's impact column up to scale; a unit-effect normalization fixes the scale and sign. Nothing is assumed about the other columns of B — all you need if one shock is the question.
Assumptions. The instrument is relevant (\(\mathbb{E}[z\varepsilon_1]\ne0\))
and exogenous (\(\mathbb{E}[z\varepsilon_j]=0\) for \(j\ne1\)). Relevance is
testable (the first-stage F); exogeneity is the identifying assumption you must
defend. A weak proxy makes the normalized IRFs heavy-tailed and conventional
bands junk — check first_stage_f first.
When to use (and when not). Use with a measured surprise or narrative series (high-frequency futures surprises, Romer-Romer shocks) — especially when the system contains fast-moving financial variables that admit no defensible Cholesky ordering. Do not report a point IRF as if it had a band (this build is point-only; valid bands need the Jentsch-Lunsford 2019 moving-block bootstrap, a documented v2 item); do not proceed on a first-stage F below ~10.
Key arguments and defaults (and why). proxy aligns to data rows (length
n_obs — the first lags presample rows are dropped — or the residual length
T); NaN entries outside the instrument's availability window are dropped
from the moments and the first stage, so a short/gappy proxy is handled
correctly. norm_var=0 and unit=1.0 set the normalization (a positive shock
raises norm_var by unit on impact). lags, horizon, trend,
robust_f=True.
How to read the output. impact/relative_impact (the identified column,
normalized), irf [h][n], first_stage_f (weak below 10), reliability
= Corr(m, u_norm)² (how much of the normalized residual the proxy explains),
cov_um (the raw residual-instrument covariances), n_proxy (effective
non-missing obs), and the estimated structural shock (length T).
Failure modes. A weak instrument reported with delta-method bands (the cardinal sin — there are no bands here for exactly this reason); dividing by a near-zero impact coefficient in the normalization (fragility); silently truncating a short proxy to the overlap and misaligning it with the residuals — which the NaN-drop path is designed to prevent.
Validated against. An independent reference — statsmodels VAR for the reduced
form and its MA representation, plus plain-NumPy method-of-moments for the
identification (proxy_svar.json,
proxy.rs).
References. Mertens & Ravn (2013); Gertler & Karadi (2015); Stock & Watson (2018); Montiel Olea, Stock & Watson (2021, weak-IV-robust bands).
import numpy as np, tsecon
rng = np.random.default_rng(5)
T = 500
eps = rng.standard_normal((T, 3)) # structural: [output, prices, policy]
mono = eps[:, 2] # the policy shock is column 2
B0 = np.array([[0.8, -0.2, -0.5], # variables: output, prices, ffr
[0.3, 0.7, -0.4],
[0.1, 0.2, 0.9]])
A1 = np.array([[0.5, 0.0, -0.1],
[0.1, 0.4, 0.0],
[0.0, 0.1, 0.6]])
y = np.zeros((T, 3))
for t in range(1, T):
y[t] = A1 @ y[t - 1] + B0 @ eps[t]
proxy = mono + 0.7 * rng.standard_normal(T) # noisy measure of the policy shock
proxy[:120] = np.nan # unavailable early in the sample
pr = tsecon.proxy_svar(y, proxy, lags=2, horizon=16, norm_var=2, unit=1.0)
print("first-stage F (weak below 10):", round(pr["first_stage_f"], 2))
print("reliability Corr(m,u)^2:", round(pr["reliability"], 4), " effective obs:", pr["n_proxy"])
irf = np.asarray(pr["irf"])
print("ffr response h = 0, 1, 4, 8:", np.round(irf[[0, 1, 4, 8], 2], 4))
print("output response h = 0, 1, 4, 8:", np.round(irf[[0, 1, 4, 8], 0], 4))
first-stage F (weak below 10): 475.45
reliability Corr(m,u)^2: 0.5797 effective obs: 380
ffr response h = 0, 1, 4, 8: [1. 0.5947 0.1548 0.0265]
output response h = 0, 1, 4, 8: [-0.6957 -0.3841 -0.0914 -0.0147]
The proxy is strong (F ≈ 475) and available on 380 of 500 observations; the unit-effect normalization sets the impact on the policy rate to exactly 1, and output falls on impact — the contractionary-policy pattern, identified from one column with no assumption on the rest of the system.
hetero_svar — identification through heteroskedasticity¶
What it estimates. The constant SVAR impact matrix B from two known variance regimes (Rigobon 2003; Lanne-Lütkepohl 2008). The two within-regime residual covariances satisfy \(\Sigma_1 = B\Lambda_1 B'\) and \(\Sigma_2 = B\Lambda_2 B'\) with \(\Lambda_r\) diagonal; a generalized eigendecomposition recovers B (up to column sign and order) — point-identified iff the structural-shock variance ratios are pairwise distinct. No zeros, no signs, no instruments: identification bought purely from second moments shifting.
Assumptions. The regime dates are known and correct; B is genuinely constant across regimes; and the relative variances genuinely differ. The recovered shocks are statistically identified and carry no economic labels — shock 2 is "the one whose variance rose most," not "the monetary shock," until you attach meaning via sign patterns or an external correlation.
When to use (and when not). Use with documented variance shifts — crisis vs.
calm windows, FOMC-announcement vs. control days (the Rigobon-Sack event-study
variant). Do not use when the relative variances barely differ (identification is
near-singular, with tight-looking bogus errors — read min_ratio_gap); do not
plot an unlabeled statistical shock as if it were a named structural shock.
Key arguments and defaults (and why). regime_labels — length T with exactly
two distinct integer values, aligned to observations (the first lags are
dropped to match residuals). base_regime is the label normalized to \(\Lambda=I\)
(default: the smaller label). lags, horizon, trend. sign_normalization:
"max" (largest-magnitude entry per B column made positive; default) or
"diag" (diagonal of B made non-negative).
How to read the output. B (the impact matrix; columns ordered by ascending
variance ratio), variance_ratios (the generalized eigenvalues — regime 2's
shock variances relative to regime 1's), structural_irf [h][i][j],
min_ratio_gap and ratio_dist_from_unity (the identification margins —
larger is better), identified (a bool heuristic), covariance_equality (a
Bartlett-corrected Box's M test that the two regimes' covariances actually
differ — its pvalue should be small), the two sigma_regime*, regime_sizes,
and sign_convention. No standard errors in this closed-form build.
Failure modes. Similar variance ratios across two shocks → their columns of B are near-unidentified (garbage estimates, bogus tight errors); mislabeling a statistical shock; regimes that do not actually differ in covariance (the Box's M test guards this).
Validated against. An independent NumPy/SciPy reference for the exact
estimator — pooled OLS reduced form and the generalized eigenproblem in NumPy,
recovering a known B from a simulated two-regime DGP
(hetero_svar.json,
hetero.rs).
References. Rigobon (2003); Rigobon & Sack (2004); Lanne & Lütkepohl (2008).
import numpy as np, tsecon
rng = np.random.default_rng(9)
T = 1000
B = np.array([[1.0, 0.5],
[0.4, 1.0]]) # true impact matrix (constant across regimes)
labels = np.zeros(T, dtype=int)
labels[T // 2:] = 1 # regime 0 first half, regime 1 second half
y = np.zeros((T, 2))
for t in range(T):
scale = np.array([1.0, 1.0]) if labels[t] == 0 else np.array([2.0, 1.0])
y[t] = B @ (rng.standard_normal(2) * scale) # shock 0's variance quadruples in regime 1
het = tsecon.hetero_svar(y, labels, lags=1, horizon=8)
print("identified:", het["identified"], " min variance-ratio gap:", round(het["min_ratio_gap"], 3))
print("variance ratios (regime 1 / regime 0):", np.round(np.asarray(het["variance_ratios"]), 3))
print("recovered B (columns ordered by variance ratio):\n", np.round(np.asarray(het["B"]), 4))
ce = het["covariance_equality"]
print("regimes differ? Box's M p-value:", round(ce["pvalue"], 4))
identified: True min variance-ratio gap: 3.09
variance ratios (regime 1 / regime 0): [0.962 4.053]
recovered B (columns ordered by variance ratio):
[[0.4341 0.9798]
[1.0013 0.4017]]
regimes differ? Box's M p-value: 0.0
The variance ratios (≈1 and ≈4) recover the design — shock 0's variance
quadruples in regime 1 while shock 1's is unchanged — and are well separated
(min_ratio_gap ≈ 3.09), so B is identified. The recovered columns match the
true B = [[1, 0.5], [0.4, 1]] up to the variance-ratio ordering and scale: the
low-ratio column ≈ true shock 1 [0.5, 1], the high-ratio column ≈ true shock 0
[1, 0.4]. Box's M rejects covariance equality (p ≈ 0), confirming the two
regimes genuinely differ — the precondition for the whole scheme.
nongaussian_svar — independent-component (non-Gaussian) identification¶
What it estimates. The full structural impact matrix B in \(u_t = B\varepsilon_t\) from the reduced-form residuals alone — no sign, zero, long-run, proxy, or variance-regime restriction — by exploiting the statistical independence and non-Gaussianity of the structural shocks (Lanne, Meitz & Saikkonen 2017; Gouriéroux, Monfort & Renne 2017). It whitens the residuals by \(\Sigma_u^{-1/2}\), rotates them to be maximally non-Gaussian with a deterministic symmetric FastICA fixed point (Hyvärinen's log-cosh contrast, identity initialization — bit-reproducible, no RNG), and sets \(B = \Sigma_u^{1/2} Q\) for the recovered orthogonal rotation \(Q\). By the ICA theorem (Comon 1994) B is point-identified up to column sign and order iff at most one structural shock is Gaussian.
Assumptions. The structural shocks are mutually independent — strictly stronger than the orthogonality every SVAR assumes — and at most one is Gaussian. Independence is itself an economic claim, and the honest open problem of the whole family: two shocks driven by a common volatility factor are dependent and violate it silently (Montiel Olea, Plagborg-Møller & Qian 2022; Drautzburg & Wright 2023 relax independence into bounds). Plus a correct reduced form and enough non-Gaussianity to estimate — heavier tails or stronger skew give sharper identification.
When to use (and when not). Use when you distrust every economic restriction on hand — no defensible recursive ordering, no credible instrument, no documented variance regime — but the shocks are plausibly non-Gaussian (fat-tailed financial innovations, skewed macro shocks). It is the data-driven fallback: identification is bought from the shape of the shock distribution, not a story you must defend. Do not use it when the shocks are near-Gaussian (it fails — see below), when independence is implausible (a common-volatility system), or as a labeled scheme without corroboration: the recovered columns are statistically identified shocks with no economic names until you check their IRF signs or an external correlation.
It FAILS under Gaussianity — and says so. Gaussian shocks have zero excess
kurtosis, and every orthogonal rotation of a whitened Gaussian vector is again
i.i.d. Gaussian — there is no "most non-Gaussian" direction to find, so B is not
identified. This is the theorem's boundary, not a numerical nuisance: the method
has nothing to exploit. The shock_kurtosis diagnostic is the tell — a value near
zero flags a column whose shock is near-Gaussian and therefore weakly (or not)
identified. The example below shows it directly: swap in Gaussian shocks and the
kurtoses collapse to ≈0 while the recovered B drifts far from the truth.
Column sign and order are conventions. ICA recovers the shocks only up to
which column is which and each column's sign — the math cannot know that
"column 0 is the demand shock" or that a positive shock raises output.
order_by="kurtosis" (default) orders columns by descending |excess kurtosis|
(most non-Gaussian first); "colnorm" orders by impact-column norm. Each column is
then signed so its largest-magnitude entry is positive. Both are labels you
impose, exactly as in hetero_svar — reorder or re-sign to match your economic
reading and it is the same model.
Key arguments and defaults (and why). lags, horizon, trend="c".
contrast="logcosh" is Hyvärinen's general-purpose robust nonlinearity (the
FastICA default). max_iter=200, tol=1e-8 govern the symmetric fixed-point
iteration — from the identity initialization it is deterministic and typically
converges in a handful of steps (n_iter reports how many, converged whether
tol was met). order_by="kurtosis" / "colnorm" chooses the column-ordering
convention.
How to read the output. impact (B — its columns are the
one-standard-deviation structural shocks, \(BB' = \Sigma_u\) exactly),
rotation (the orthogonal \(Q\) acting on the whitened residuals), irf [h][i][j]
(the structural IRF, irf[0] \(=\) impact), shock_kurtosis [j] (each
identified shock's excess kurtosis, in the reported order — the
identification-strength diagnostic; near zero ⇒ weak or unidentified), order
(the permutation applied), and converged/n_iter. No standard errors in this
build — an honest bootstrap band is a v2 item.
Failure modes. Near-Gaussian shocks (identification silently vanishes — read
shock_kurtosis); genuinely dependent shocks violating the independence
assumption (the ICA estimand is then not the structural B); reading an unlabeled
statistical shock as a named one; too few observations to pin down the higher
moments the contrast leans on (the weakest-kurtosis column degrades first).
Validated against. An independent NumPy FastICA pipeline
(numpy.linalg.lstsq OLS, numpy.linalg.eigh for the whitening inverse-square-root
and the decorrelation, numpy.tanh for the log-cosh contrast) that never imports
tsecon — a genuine cross-implementation golden bit-matching B, \(Q\), the per-shock
excess kurtosis, the structural IRF, the ordering, and the convergence
flag/iteration count (tol 1e-10; achieved ~1e-15). That NumPy reference is itself
cross-checked against sklearn.decomposition.FastICA at generation (~4e-16), so
it is a faithful FastICA, not a bespoke re-derivation. Two statistical property
checks carry the estimand: the recovered B equals the true DGP B up to
sign+permutation on simulated non-Gaussian data (MC tol 5e-2), and the ICA
rotation provably lowers fourth-order cross-dependence relative to the raw whitened
residuals; plus \(BB' = \Sigma_u\), \(Q\) orthogonal, and bit-identical
reproducibility (nongaussian_svar.json,
nongaussian.rs). The novel
ICA core is pinned exactly; the statistical-identification claim rests on the
recovery property — honestly weaker than a closed-form golden. See the
validation matrix.
References. Comon (1994); Hyvärinen & Oja (2000, FastICA); Lanne, Meitz & Saikkonen (2017, Journal of Econometrics); Gouriéroux, Monfort & Renne (2017, Journal of Econometrics); Montiel Olea, Plagborg-Møller & Qian (2022); Drautzburg & Wright (2023).
import numpy as np, tsecon
import itertools
def best_align(B_hat, B_true):
# align recovered columns to the true B up to sign + permutation (n = 3)
best, aligned = np.inf, None
for perm in itertools.permutations(range(B_true.shape[1])):
for signs in itertools.product([1, -1], repeat=B_true.shape[1]):
cand = B_hat[:, perm] * np.array(signs)
d = np.max(np.abs(cand - B_true))
if d < best:
best, aligned = d, cand
return best, aligned
rng = np.random.default_rng(0)
T = 2000
# independent, standardized Student-t(5) structural shocks (excess kurtosis = 6)
eps = rng.standard_t(5, size=(T, 3)) / np.sqrt(5 / 3)
B_true = np.array([[1.0, 0.5, -0.3], # true impact matrix, u = B eps
[0.4, 1.0, 0.2],
[-0.2, 0.3, 1.0]])
A1 = np.array([[0.5, 0.0, -0.1],
[0.1, 0.4, 0.0],
[0.0, 0.1, 0.5]])
y = np.zeros((T, 3))
u = eps @ B_true.T
for t in range(1, T):
y[t] = A1 @ y[t - 1] + u[t]
ng = tsecon.nongaussian_svar(y, lags=1, horizon=8)
print("converged:", ng["converged"], " n_iter:", ng["n_iter"],
" identified order:", np.asarray(ng["order"]))
print("shock excess kurtosis (identified order):",
np.round(np.asarray(ng["shock_kurtosis"]), 3))
err, B_aligned = best_align(np.asarray(ng["impact"]), B_true)
print("recovered B, aligned to true B up to sign+permutation:\n", np.round(B_aligned, 4))
print("max|recovered B - true B|:", round(err, 4))
# FAILS under Gaussianity: same B, Gaussian shocks -> kurtosis ~ 0, rotation arbitrary
rng2 = np.random.default_rng(1)
yG = np.zeros((T, 3))
uG = rng2.standard_normal((T, 3)) @ B_true.T
for t in range(1, T):
yG[t] = A1 @ yG[t - 1] + uG[t]
ngG = tsecon.nongaussian_svar(yG, lags=1, horizon=8)
errG, _ = best_align(np.asarray(ngG["impact"]), B_true)
print("\nGaussian shocks -- identification FAILS")
print("shock excess kurtosis (all near zero):",
np.round(np.asarray(ngG["shock_kurtosis"]), 3))
print("max|recovered B - true B|:", round(errG, 4))
converged: True n_iter: 4 identified order: [2 0 1]
shock excess kurtosis (identified order): [6.604 3.806 3.387]
recovered B, aligned to true B up to sign+permutation:
[[ 0.9998 0.5073 -0.2329]
[ 0.3814 0.9931 0.2283]
[-0.2418 0.2898 0.9486]]
max|recovered B - true B|: 0.0671
Gaussian shocks -- identification FAILS
shock excess kurtosis (all near zero): [ 0.209 -0.207 0.084]
max|recovered B - true B|: 0.5695
With independent, heavy-tailed shocks the FastICA fixed point converges in four
steps and recovers the true impact matrix to within 0.067 — no ordering, no
sign, no instrument, no variance regime spent, only the non-Gaussianity of the
shocks. The three shock_kurtosis values (6.6, 3.8, 3.4) are all comfortably
positive: the leverage is real, and the columns are ordered most-non-Gaussian
first. Feed the same system Gaussian shocks and the story collapses exactly as
the theorem promises — the excess kurtoses fall to ≈0, there is no most-non-Gaussian
direction left to find, and the recovered B wanders 0.57 from the truth. The
shock_kurtosis diagnostic is what turns that failure from silent to loud: when
it is near zero, the identification is not there to be had.
Post-identification and prior-robust tools¶
The four schemes above (and the recursive / sign / zero-sign schemes in the
VAR/SVAR card) each hand you an identification. The five tools
below answer what comes next. Three take a single structural impact matrix A0
(columns = one-standard-deviation shocks, \(A_0 A_0' = \Sigma_u\)) from any
scheme; two operate on the sign-restricted set directly.
The shared object is the structural moving-average representation \(\Theta_h = \Psi_h A_0\), where \(\Psi_h\) are the reduced-form MA weights (\(\Psi_0 = I\), \(\Psi_h = \sum_{i=1}^{\min(h,p)} \Psi_{h-i} A_i\)) and the columns of \(\Theta_h\) are the horizon-\(h\) impulse responses. Because \(A_0 = P Q\) for a lower-Cholesky \(P\) and any orthogonal \(Q\), every one of these tools reads the same \((\Psi_h, P)\) off the reduced form and differs only in what it does with the rotation \(Q\) — a fixed one, a sampled set, or the whole admissible set.
The examples below share one 3-variable macro system — output, prices, policy rate — with a genuine simultaneity between the three shocks:
import numpy as np, tsecon
rng = np.random.default_rng(7)
T = 300
eps = rng.standard_normal((T, 3)) # structural: [demand, cost, policy]
B0 = np.array([[0.8, 0.4, -0.3], # variables: output, prices, ffr
[0.2, 0.9, -0.2],
[0.3, -0.1, 0.7]])
A1 = np.array([[0.5, 0.0, -0.1],
[0.1, 0.4, 0.0],
[0.0, 0.1, 0.6]])
data = np.zeros((T, 3))
for t in range(1, T):
data[t] = A1 @ data[t - 1] + B0 @ eps[t]
structural_fevd — variance decomposition for an arbitrary impact matrix¶
What it estimates. The forecast-error variance decomposition
fevd[h][i][j] — the share of variable \(i\)'s \((h{+}1)\)-step forecast-error
variance attributable to structural shock \(j\) — for a general structural
impact matrix \(A_0\). var_fevd computes this only for the recursive-Cholesky
\(A_0 = P\); structural_fevd fills the gap, accepting the \(A_0\) from a sign-,
zero-, proxy-, max-share-, long-run-, or heteroskedasticity-identified model.
The share is \(\omega_{ij}(h) = \big[\sum_{s\le h}\Theta_s[i,j]^2\big] /
\big[\sum_m\sum_{s\le h}\Theta_s[i,m]^2\big]\) with \(\Theta_s = \Psi_s A_0\).
Assumptions. A correct reduced form and an \(A_0\) that satisfies \(A_0 A_0' = \Sigma_u\). That is the only requirement — the shares inherit whatever identification produced \(A_0\), and carry no more economic content than it does.
The invariant that makes it honest. The denominator — variable \(i\)'s total
\((h{+}1)\)-step forecast MSE — is rotation-invariant: \(A_0 A_0' = PQQ'P' =
\Sigma_u\) regardless of \(Q\), so the total variance being split does not depend on
the identification. Only the split across shocks \(j\) changes. Consequently each
row sums to exactly 1, and column sign-flips of \(A_0\) leave the shares unchanged
(they enter squared). With \(A_0 = P\) the result equals var_fevd and
statsmodels' VARResults.fevd exactly.
When to use (and when not). Use to report "shock \(j\) explains X% of variable
\(i\)'s variance at horizon \(h\)" after you have identified \(A_0\) — the standard
companion table to an IRF plot. Do not read a Cholesky FEVD when your shock is
sign- or proxy-identified: feed the actual \(A_0\). Do not over-interpret shares
from a set-identified scheme without checking they are stable across the
admissible rotations (that is what robust_svar_bounds is for on the IRFs).
Key arguments and defaults (and why). lags, horizon (the FEVD is
reported for steps \(0..\,\)horizon), trend="c". impact=None uses the lower
Cholesky of \(\Sigma_u\) (so the result reproduces var_fevd); pass an
\((n\times n)\) impact for any other scheme. sigma="dfadj" (default) or
"mle" sets the default Cholesky's df scaling — the shares are invariant to
it (numerator and denominator scale together); it only rescales the reported
impact.
How to read the output. fevd [horizon+1][variable][shock] (each
fevd[h][i] sums to 1), and impact [n][n] (the \(A_0\) used — the Cholesky
factor when impact=None).
Failure modes. Passing an \(A_0\) that does not satisfy \(A_0 A_0' = \Sigma_u\)
(the row sums stay 1 by construction, but the shares are then meaningless);
reading a recursive FEVD for a non-recursive shock; confusing the [h][i][j]
layout (variable then shock) with var_fevd's [i][h][j].
Validated against. statsmodels VARResults.fevd and the independent
tsecon-var var_fevd, an exact cross-implementation golden for the Cholesky
case (tol 1e-10); the general-\(A_0\) shares are pinned by the exact algebraic
invariants — row sums = 1 and denominator rotation-invariance under a random
orthogonal \(Q\) (tol 1e-12)
(structural_fevd.json,
structural_fevd.rs, 7
tests). See the validation matrix.
References. Lütkepohl (2005, §2.3.3); Kilian & Lütkepohl (2017, ch. 4).
sf = tsecon.structural_fevd(data, lags=2, horizon=12)
fevd = np.asarray(sf["fevd"]) # [h][variable][shock]
print("row sums at h=12 (each variable's shares):", np.round(fevd[12].sum(axis=1), 12))
print("ffr (variable 2) FEVD at h = 0, 4, 12:\n", np.round(fevd[[0, 4, 12], 2, :], 4))
# impact=None reproduces var_fevd exactly (aligning the two array layouts)
vf = np.asarray(tsecon.var_fevd(data, lags=2, horizon=12)) # [variable][step][shock]
print("matches var_fevd:", np.allclose(np.transpose(fevd, (1, 0, 2))[:, :12, :], vf))
# feed a rotated A0 = P @ Q: the total MSE is invariant, only the split moves
Q, _ = np.linalg.qr(rng.standard_normal((3, 3)))
sf2 = tsecon.structural_fevd(data, lags=2, horizon=12, impact=np.asarray(sf["impact"]) @ Q)
row = np.asarray(sf2["fevd"])[12, 2, :]
print("rotated-A0 ffr FEVD at h=12:", np.round(row, 4), " sum:", round(row.sum(), 12))
row sums at h=12 (each variable's shares): [1. 1. 1.]
ffr (variable 2) FEVD at h = 0, 4, 12:
[[4.000e-04 1.429e-01 8.567e-01]
[1.190e-02 9.210e-02 8.960e-01]
[1.180e-02 9.030e-02 8.979e-01]]
matches var_fevd: True
rotated-A0 ffr FEVD at h=12: [0.0377 0.299 0.6633] sum: 1.0
Under the Cholesky ordering the funds rate's own shock explains 86% of its one-step forecast error and 90% by horizon 12. Rotate the impact matrix and the split changes completely (4% / 30% / 66%) — yet the row still sums to exactly 1, because the total variance being decomposed is the reduced-form object the rotation cannot touch. That is the whole point: the FEVD is only as identified as the \(A_0\) you feed it.
historical_decomposition — who drove each observation¶
What it estimates. The exact split of each realized observation into a
deterministic/initial-condition baseline plus the cumulated contribution of
each structural shock: hd[t][i][j] is shock \(j\)'s contribution to variable \(i\)
at effective date \(t\), with \(\mathrm{hd}[t,i,j] = \sum_{s=0}^{t} \Theta_s[i,j]\,
\varepsilon_{t-s,j}\). It answers "how much did shock \(j\) contribute to variable
\(i\) during episode X" — the Kilian & Lütkepohl (2017, ch. 4) historical
decomposition, and the hard prerequisite for narrative_svar.
The adding-up identity. For any invertible \(A_0\), $\(y_{t,i} = \mathrm{baseline}[t,i] + \sum_{j} \mathrm{hd}[t,i,j]\)$ holds exactly — not asymptotically — because \(y - \mathrm{baseline}\) is the finite truncated MA sum from the initial condition, and the presample shocks are fully absorbed into the baseline. The example below verifies it to \(\sim10^{-15}\).
Assumptions. A correct reduced form and an \(A_0\). In the default
identification="cholesky" mode the decomposition is exactly identified given
the reduced form — the only modeling choice is the ordering. In
identification="sign" mode the contributions become a set, summarized over the
sign- (and optionally narrative-) restricted rotations.
When to use (and when not). Use to attribute a specific historical episode —
"the 1979-82 funds-rate run-up was N% monetary shock" — or to plot the shock
contributions to a variable over time. Do not read the cholesky-mode
contributions as sign-identified shocks: in that mode the shocks are the
recursive ones (variable \(i\)'s own orthogonalized innovation is shock \(i\)). For a
set-identified scheme pass identification="sign" with restrictions.
Key arguments and defaults (and why). restrictions — traditional
(variable, shock, horizon, sign) tuples, needed only for
identification="sign". lags, horizon=None (the MA is truncated at the exact
\(T_{\mathrm{eff}}-1\) by default). identification="cholesky" (point, \(Q=I\)) or
"sign" (set). n_draws, max_tries, seed, lambda1 control the sampler in
sign mode; narrative_restrictions and n_weight_draws add episode restrictions
(see narrative_svar).
How to read the output. times (0-based effective-sample indices, \(=\)
data_row - lags), baseline [T_eff][n]. In cholesky mode: hd
[T_eff][variable][shock] and the structural shocks [T_eff][n]. In sign
mode: probs, hd_quantiles [T_eff][n][n][len(probs)] (weighted type-7), the
weight-free hd_set_min/hd_set_max envelope, per-draw weights, and
diagnostics.
Failure modes. Reading cholesky-mode "shock 2" as an economically named
shock (it is the third variable's recursive innovation); a singular \(A_0\) (the
structural shocks \(\varepsilon = A_0^{-1}u\) are then undefined — reported as an
error); off-by-lags alignment between times and the original data rows.
Validated against. A self-contained NumPy closed-form reference that fits a
fixed VAR(2) by OLS, Cholesky-identifies, and computes \(\varepsilon\), \(\Theta_s\),
hd, and baseline — matched cell-by-cell (rtol 1e-8, atol 1e-10), with the
adding-up residual \(\max|y - \mathrm{baseline} - \sum_j \mathrm{hd}| < 10^{-9}\)
(historical_decomposition_chol.json,
historical_decomposition.rs
plus the shocks.rs/histdecomp.rs unit tests).
References. Kilian & Lütkepohl (2017, ch. 4); Antolín-Díaz & Rubio-Ramírez (2018, for the sign-mode set version).
hd = tsecon.historical_decomposition(data, lags=2, identification="cholesky")
contrib = np.asarray(hd["hd"]) # [t][variable][shock]
base = np.asarray(hd["baseline"]) # [t][variable]
y_eff = data[2:] # the effective sample (lags dropped)
print("adding-up max|y - baseline - sum_j hd|:",
np.max(np.abs(y_eff - (base + contrib.sum(axis=2)))))
t = 150
print(f"at t={t}: ffr actual {y_eff[t, 2]:+.4f} baseline {base[t, 2]:+.4f}")
print(" ffr contributions from shocks [0, 1, 2]:", np.round(contrib[t, 2, :], 4))
adding-up max|y - baseline - sum_j hd|: 2.6645352591003757e-15
at t=150: ffr actual +0.1345 baseline -0.2017
ffr contributions from shocks [0, 1, 2]: [-0.0845 0.3348 0.0859]
The identity holds to machine precision, and the funds rate's deviation from its
baseline at \(t=150\) is decomposed into the three recursive shocks — here the
second shock (the price equation's innovation) is doing most of the work. Swap in
identification="sign" with the restrictions below and each hd[t][i][j] becomes
a band over the admissible monetary-shock rotations instead of a point.
fry_pagan_svar — the coherent draw the median band is not¶
What it estimates. The single accepted, sign-normalized structural draw whose
IRFs are jointly closest to the pointwise median — the Fry-Pagan (2011)
median-target rotation. Sign restrictions identify a set of models; the
pointwise median band stitches together responses from mutually inconsistent
draws (the horizon-3 median and the horizon-8 median generally come from
different rotations), so it is not the IRF of any admissible model.
fry_pagan_svar returns one that is.
The criterion. Over a set of target cells \(\mathcal{C}\) (by default all response cells of the sign-restricted shocks, every variable and horizon), the median-target statistic is \(\mathrm{MT}(d) = \sum_{(i,j,h)\in\mathcal{C}} z^{(d)}_{i,j,h}{}^2\) where \(z^{(d)} = (\Theta^{(d)} - \mathrm{median})/\mathrm{sd}\) is each draw's standardized deviation from the pointwise median. The selected draw is \(d^\star = \arg\min_d \mathrm{MT}(d)\) — the interior point of the identified set that is internally coherent and central.
Assumptions. Everything sign_restricted_svar assumes, plus the honest
caveat that the selected draw is a descriptive summary, not a point estimate:
it is one interior point of a set, and which point depends on the informative
Haar prior over rotations. It answers "give me one coherent model near the middle
of the band," not "give me the identified impulse response."
When to use (and when not). Use to report a single set of numbers — an IRF
table, an \(A_0\) to feed structural_fevd or historical_decomposition — that
comes from one real model rather than a mix. Do not present it as the estimate,
and do not drop the band: the median-target IRF is a companion to the identified
set, not a replacement. When the prior matters, pair it with
robust_svar_bounds.
Key arguments and defaults (and why). restrictions (required) — the
(variable, shock, horizon, sign) tuples. lags, horizon, n_draws=500,
max_tries=400, seed=0, lambda1=0.2 — same sampler as
sign_restricted_svar. target="restricted" scores only the response cells of
the sign-restricted shocks (default); "all" scores every cell.
How to read the output. median_target_irf [horizon+1][n][n] (the coherent
Fry-Pagan IRF — its [0] slice is a valid \(A_0\)), median_irf (the incoherent
pointwise median, for side-by-side), mt_index (0-based into the accepted set),
mt_statistic, n_accepted, and diagnostics
(posterior_draws_used/rotations_tried/accepted/acceptance_rate).
Reproducible bit-for-bit at a fixed seed.
Failure modes. Reporting the median-target IRF without the band (it hides the
set-identification width, which is the finding); reading it as prior-free (the
Haar prior selects which interior point); too few accepted draws to estimate a
stable pointwise median (watch n_accepted).
Validated against. A stored fixture of \(D\) candidate structural IRFs (seeded
NumPy Haar rotations of a fixed Cholesky IRF, sign-filtered) with an independent
NumPy computation of the median, dispersion, \(\mathrm{MT}(d)\), and \(\arg\min\);
the Rust selection must return the same mt_index and mt_statistic (tol
1e-10), plus end-to-end seed reproducibility
(fry_pagan_svar.json,
fry_pagan.rs). The selection
rule is validated exactly; the estimand inherits the set-identification
caveat.
References. Fry & Pagan (2011, Journal of Economic Literature).
# policy shock (2): raises the funds rate, lowers output and prices on impact
restr = [(2, 2, 0, "+"), (0, 2, 0, "-"), (1, 2, 0, "-")]
fp = tsecon.fry_pagan_svar(data, restr, lags=2, horizon=12, n_draws=500, seed=0)
print("n_accepted:", fp["n_accepted"], " mt_index:", fp["mt_index"],
" mt_statistic:", round(fp["mt_statistic"], 4))
mt = np.asarray(fp["median_target_irf"]); med = np.asarray(fp["median_irf"])
print("coherent output<-policy h = 0, 2, 4, 8:", np.round(mt[[0, 2, 4, 8], 0, 2], 4))
print("pointwise output<-policy h = 0, 2, 4, 8:", np.round(med[[0, 2, 4, 8], 0, 2], 4))
n_accepted: 500 mt_index: 348 mt_statistic: 1.9921
coherent output<-policy h = 0, 2, 4, 8: [-0.3532 -0.0762 -0.0159 -0.0012]
pointwise output<-policy h = 0, 2, 4, 8: [-0.4892 -0.0758 -0.0127 -0.0007]
Draw 348 of the 500 accepted is the single most central coherent model. Its output-on-impact response (\(-0.35\)) differs from the pointwise median (\(-0.49\)) precisely because the pointwise median is not a model — no single admissible rotation produces the \(-0.49\) impact together with the median responses at every other horizon. Read the two together: the band for the set, the median-target for one model that lives inside it.
robust_svar_bounds — the identified set without the Haar artifact¶
What it estimates. The Giacomini-Kitagawa (2021) prior-robust identified-set
bounds. For each restricted shock and each response cell \((h, i, j)\), and each
reduced-form posterior draw, it computes the exact min and max of the
structural IRF over the entire admissible rotation set — not a sampled interval,
the whole set. It then summarizes those per-draw edges across the posterior. This
removes the informative-Haar-prior artifact that the pointwise
sign_restricted_svar bands carry: because the data cannot distinguish points
within the identified set, any single prior on rotations (the Haar default
included) injects information the data never provided, and that never washes out
(Baumeister-Hamilton 2015).
The closed form. For a shock restricted alone, each restriction is a linear inequality \(a_k' q_j \ge 0\) on that shock's rotation column, and the IRF \(\eta = g' q_j\) is optimized over \(\{\|q\|=1,\ a_k'q\ge0\}\) — a quadratically constrained linear program whose optimum is a KKT point found by active-set enumeration (Gafarov-Meier-Montiel-Olea 2018). This is exact for a single restricted shock. With several jointly-restricted shocks the admissible columns must be mutually orthogonal, the per-column problem no longer decouples, and each reported bound is that shock's marginal identified set — a conservative outer approximation of the joint set, flagged honestly rather than oversold.
Assumptions. A correct reduced form and sign restrictions that are feasible for at least some draws. The Minnesota-NIW posterior on the reduced form supplies the draws; the rotation prior is exactly what this method refuses to commit to.
When to use (and when not). Use for any set-identified result headed for publication: report the robust bounds alongside the sign-restricted band so a reader can see how much of the band's apparent sharpness was prior rather than data (if the robust region is much wider, the gap is the Haar artifact). Do not use it as a point estimate; do not read the multi-shock bounds as certified joint bounds — each is a per-shock marginal set that is a conservative outer approximation of the true joint region (consistent with the "conservative outer approximation" note above), never an inner one.
Key arguments and defaults (and why). restrictions (required). lags,
horizon, n_draws=500, seed=0, lambda1=0.2. alpha=0.10 sets the robust
credible level (0.10 → a 90% robust credible region).
How to read the output. Per [horizon+1][variable][shock]:
set_lower_mean/set_upper_mean (posterior-mean identified-set edges,
\(\hat{E}[l]\)/\(\hat{E}[u]\)), robust_ci_lower/robust_ci_upper (the level-alpha
robust credible region — the \(\alpha/2\) quantile of the lower edges and the
\(1-\alpha/2\) quantile of the upper edges), and lower_quantiles/upper_quantiles
at probs. Unrestricted shocks are NaN; restricted_shocks lists the valid
\(j\); diagnostics reports empty_set_rate (the share of draws whose restrictions
were mutually infeasible — a first-order GK diagnostic).
Failure modes. Treating the multi-shock bounds as exact joint bounds
(they are marginal); a high empty_set_rate signalling near-inconsistent
restrictions; reading the robust region as narrower than the sign band and
concluding the data are sharp — it is the opposite (the robust region is the
honest, wider object).
Validated against. An independent NumPy implementation of the
Gafarov-Meier-Montiel-Olea (2018) active-set closed form for a fixed
\((B, \Sigma)\) and single-shock restrictions (tol 1e-8), plus a brute-force
random-sphere search (\(\ge10^6\) feasible unit vectors) that must bracket the
analytic optimum from the inside, and a NumPy aggregation golden for the
set-mean and robust-region quantiles
(robust_svar_bounds.json,
robust_bounds.rs, 7 tests).
Strong for a single restricted shock; moderate (inside-bracket only) for the
multi-shock path.
References. Giacomini & Kitagawa (2021, Econometrica); Gafarov, Meier & Montiel Olea (2018, Journal of Econometrics); Baumeister & Hamilton (2015).
rb = tsecon.robust_svar_bounds(data, restr, lags=2, horizon=12, n_draws=500,
seed=0, alpha=0.10)
print("restricted_shocks:", rb["restricted_shocks"], " empty_set_rate:",
rb["diagnostics"]["empty_set_rate"])
lo = np.asarray(rb["set_lower_mean"]); hi = np.asarray(rb["set_upper_mean"])
cil = np.asarray(rb["robust_ci_lower"]); cih = np.asarray(rb["robust_ci_upper"])
for h in [0, 2, 4]:
print(f"h={h} output<-policy set-mean [{lo[h,0,2]:+.4f}, {hi[h,0,2]:+.4f}]"
f" 90% robust CI [{cil[h,0,2]:+.4f}, {cih[h,0,2]:+.4f}]")
print("unrestricted shock 0 is NaN:", bool(np.isnan(lo[0, 0, 0])))
restricted_shocks: [2] empty_set_rate: 0.0
h=0 output<-policy set-mean [-0.9062, +0.0000] 90% robust CI [-0.9716, +0.0000]
h=2 output<-policy set-mean [-0.1559, +0.0341] 90% robust CI [-0.2329, +0.0978]
h=4 output<-policy set-mean [-0.0376, +0.0203] 90% robust CI [-0.0750, +0.0590]
The impact bound's upper edge is exactly zero — the sign restriction
\((0,2,0,\text{"-"})\) forces output's on-impact response to the policy shock to be
\(\le 0\), and the exact identified-set optimizer honors it to the last digit. Only
shock 2 is restricted, so shocks 0 and 1 return NaN. Away from impact the set
straddles zero (e.g. \([-0.16, +0.03]\) at \(h=2\)): the sign restrictions pin the
sign on impact but not the persistence, and the robust bounds say so without
borrowing sharpness from the rotation prior.
narrative_svar — episode knowledge from the historical record¶
What it estimates. The Antolín-Díaz & Rubio-Ramírez (2018) narrative
sign-restricted SVAR: sign_restricted_svar augmented with restrictions on named
historical episodes — the sign of a structural shock in a specific quarter, or a
"most/least important contributor" statement about a shock's role in a variable's
historical decomposition over an episode. It is a strict superset of
sign_restricted_svar (with no narrative restrictions it reproduces it
bit-for-bit).
How the episodes enter. Shock-sign restrictions constrain the per-shock
orientation jointly with the traditional signs. Contribution restrictions are
checked on the historical decomposition (orientation-free, since both
\(\Theta\) and \(\varepsilon\) flip together). The AD&RR estimator keeps the
reduced-form marginal at the traditional posterior and imposes the narrative event
\(N\) by importance-reweighting: each accepted draw \(m\) carries weight
\(w^{(m)} = 1/\hat{P}(N\mid S, \phi^{(m)})\), where \(\hat{P}\) is a Monte-Carlo
estimate over n_weight_draws sign-passing rotations. A draw whose
narrative-admissible slice of the identified set is small is up-weighted, so all
bands and quantiles become weighted.
Assumptions. Everything sign_restricted_svar assumes, plus that your
episode statements are true of the data-generating process — a claim you defend
by reading the same historical record the restriction encodes. The honest caveat:
\(1/\hat{P}\) is a biased (Jensen) estimator of \(1/P(N\mid S)\), so use
n_weight_draws \(\ge 100\) and watch the effective sample size — heavy-tailed
weights are the method's characteristic failure.
When to use (and when not). Use when you have credible episode knowledge — "the
monetary shock was contractionary in October 1979, and it was the dominant driver
of that quarter's funds-rate move" — and want to shrink a wide sign-identified set.
Do not use it to rescue restrictions the data reject (a low
narrative_acceptance_rate with a collapsing ess means the narrative is fighting
the traditional posterior); do not ignore the weights when reading the bands.
Key arguments and defaults (and why). sign_restrictions (the traditional
tuples; may be empty if narrative restrictions are given), narrative_restrictions
(a list of dicts, schema below), lags, horizon, n_draws, max_tries,
seed, lambda1, and n_weight_draws=200 (the \(K_w\) for \(\hat{P}\)). The dict
schemas use 0-based effective-sample indices (\(=\) data_row - lags):
{"type": "shock_sign", "shock": int, "period": int, "sign": "+"|"-"}
{"type": "contribution", "variable": int, "shock": int, "start": int, "end": int,
"rule": "most"|"least", "strong": bool}
{"type": "contribution_sign", "variable": int, "shock": int,
"start": int, "end": int, "sign": "+"|"-"}
How to read the output. Same shape as sign_restricted_svar —
quantiles [horizon+1][n][n][len(probs)] (weighted type-7 at probs =
[0.05, 0.16, 0.50, 0.84, 0.95]), the weight-free set_min/set_max envelope —
plus weights (per accepted draw, mean 1) and an extended diagnostics:
narrative_accepted, narrative_acceptance_rate, ess (effective sample size),
mean_weight, and min_ptilde (the smallest \(\hat{P}\) — a small value flags a
draw carrying a large weight).
Failure modes. A collapsing ess (a few draws carrying all the weight —
the bands are then unreliable); reading a redundant narrative (one already implied
by the traditional signs) as informative (its weights are ~uniform and the bands
barely move); off-by-lags episode indices.
Validated against. Reweighting-invariance (no narrative ⇒ every weight 1 and
quantiles equal sign_restricted_svar bit-for-bit; a redundant narrative ⇒
\(\hat{P}=1\), uniform weights, bands unchanged to 1e-12) and a deterministic
weight-formula unit test against a brute-force high-\(K\) Monte-Carlo \(P(N\mid S)\);
the underlying HD core carries the strong closed-form golden above
(narrative.rs unit tests).
Set-identified and statistical — honestly weaker than the HD golden, validated by
property rather than a golden posterior.
References. Antolín-Díaz & Rubio-Ramírez (2018, American Economic Review);
the bsvarSIGNs R package implements the same estimator.
# by construction, the largest policy innovation lands in this quarter
peak = int(np.argmax(eps[2:, 2])) # effective-sample index = 136
# episode: the policy shock (2) was the MOST important driver of the ffr (2) over [peak-2, peak+2]
narr = [{"type": "contribution", "variable": 2, "shock": 2,
"start": peak - 2, "end": peak + 2, "rule": "most", "strong": False}]
nv = tsecon.narrative_svar(data, restr, narr, lags=2, horizon=12,
n_draws=500, seed=0, n_weight_draws=200)
d = nv["diagnostics"]
print("accepted:", d["accepted"], " narrative_acceptance_rate:",
round(d["narrative_acceptance_rate"], 3), " ess:", round(d["ess"], 1),
" min_ptilde:", round(d["min_ptilde"], 3))
base = tsecon.sign_restricted_svar(data, restr, lags=2, horizon=12, n_draws=500, seed=0)
qb = np.asarray(base["quantiles"]); qn = np.asarray(nv["quantiles"])
for h in [0, 2, 4]: # output<-policy: median and 5-95 width
mb, wb = qb[h,0,2,2], qb[h,0,2,4]-qb[h,0,2,0]
mn, wn = qn[h,0,2,2], qn[h,0,2,4]-qn[h,0,2,0]
print(f"h={h}: plain median {mb:+.4f} (width {wb:.4f}) | narrative {mn:+.4f} (width {wn:.4f})")
# with no narrative restrictions it IS sign_restricted_svar
none = tsecon.narrative_svar(data, restr, None, lags=2, horizon=12, n_draws=500, seed=0)
print("narrative=None reproduces sign_restricted_svar:",
np.array_equal(np.asarray(none["quantiles"]), qb))
accepted: 163 narrative_acceptance_rate: 0.326 ess: 143.8 min_ptilde: 0.124
h=0: plain median -0.4892 (width 0.8097) | narrative -0.2131 (width 0.6648)
h=2: plain median -0.0758 (width 0.2108) | narrative -0.0423 (width 0.1599)
h=4: plain median -0.0127 (width 0.0814) | narrative -0.0089 (width 0.0742)
narrative=None reproduces sign_restricted_svar: True
The narrative binds: only a third of the sign-passing rotations (rate 0.326)
also make the policy shock the dominant driver of the funds rate in that episode,
and the smallest \(\hat{P}\) (0.124) marks a draw whose slice is narrow enough to
earn an eightfold weight. The reweighting both shifts the output-on-impact median
(from \(-0.49\) toward \(-0.21\)) and narrows the band (0.81 → 0.66) — episode
knowledge, imposed as an importance weight, is doing real work. And with no
narrative restriction the function is exactly sign_restricted_svar, so it is a
safe drop-in default. A shock-sign restriction that merely agrees with the
impact signs is nearly redundant instead — \(\hat{P}\approx0.98\), weights ~uniform,
bands unchanged — which is the reweighting-invariance check the tests pin down.