🇬🇧 English · 🇪🇸 Español
VAR & FAVAR
puremacro.var fits a reduced-form VAR and then answers the only question a
reduced form cannot: which of the correlated innovations was the shock. Every
identification scheme in puremacro.var.identify takes the same reduced form
and differs only in what it is willing to assume — a recursive ordering, a
long-run neutrality, a sign pattern, an external instrument, a variance break.
Picking between them is the modelling decision; the code deliberately makes them
interchangeable so the choice can be made on economics rather than on which one
was easiest to wire up.
Nothing on this page touches the network. Everything takes an ndarray; favar
also accepts a DataFrame and keeps its column names.
from puremacro.var import lag_select
from puremacro.var.identify import cholesky
Y = ... # (T, n) ndarray, rows time-ordered
p = lag_select(Y, maxlags=8, ic="bic")
res = cholesky(Y, p=p, horizon=20, n_boot=500, ci=0.9, seed=0)
print(res.summary())
res.irf_point[4, 1, 0] # variable 1 at h=4 to shock 0
The reduced form
fit_var(Y, p) is OLS with a constant. It returns a frozen
VarEstimateResult:
| attribute | shape | what it is |
|---|---|---|
A_list |
list of p arrays, each (n, n) |
A_1 … A_p, lag 1 first |
c |
(n,) |
intercept |
Sigma |
(n, n) |
residual covariance, denominator T − p − 1 − np |
resid |
(T − p, n) |
reduced-form residuals |
X |
(T − p, 1 + np) |
design matrix, constant in column 0 |
Sigma carries the full degrees-of-freedom correction, not T − p. The
dataclass is iterable and indexable, so the five-tuple unpack every internal
caller uses still works:
lag_select(Y, maxlags=8, ic="bic") returns an int; ic accepts "aic",
"hq" or "bic", and anything else falls through to BIC rather than raising.
It minimises log|Σ_p| plus the usual penalty in the coefficient count n²p
(intercepts excluded), each candidate evaluated on its own effective sample
T − p, and skips any p that fails to estimate.
companion(A_list) returns the (np, np) companion matrix and
is_stable(A_list) tests max |eig| < 1.0 on it.
irf, fevd and historical_decomp in puremacro.var.irf work off
(A_list, B0) directly — historical_decomp wants the residuals as well — for
when you have an impact matrix from somewhere else. gfevd takes
(A_list, Sigma) instead and needs no impact matrix at all: it is the
Pesaran–Shin generalised decomposition — order-invariant, rows that do not
naturally sum to one, rescaled to sum to one under the default normalize=True
(the Diebold–Yılmaz convention).
(H+1, n, n) — say it out loud once
Every IRF, FEVD and band array in this package is (horizon, response,
shock). irf_point[h, i, j] is the response of variable i at horizon h
to a one-standard-deviation shock in structural equation j. Horizon 0 is
impact, so an array with horizon=20 has 21 rows.
This is worth stating because the two axes that are not the horizon are both
length n and a transposition is silent: it produces plausible-looking impulse
responses with the roles of response and shock swapped. ProxySVARResult
shipped as (n, n, H+1) until 0.47.0 flipped it to match the other result
dataclasses. The inference layer still uses the other convention —
inference.wild_bootstrap.wild_bootstrap_var returns (n, n, H+1) and
proxy_svar transposes it back on the way out — so if you write your own
impact_fn and call that layer directly, you must transpose yourself.
The exceptions are the results that have no n shocks to report.
GKRobustBandsResult's arrays are (H+1, n) — it describes a single named
shock, so the shock axis is gone rather than degenerate — FAVARResult's panel
frames are (H+1, N) for the same reason, and the regime GIRFs in
puremacro.var.regime carry a regime axis instead (last section).
The identification schemes
Everything in the table is importable from puremacro.var.identify. The
"needs" column names the argument that carries the assumption — what you decide
beyond Y, p and horizon. Several of them have a default
(permanent_var_idx=0, target_idx=0, max_fev_at=1, shock_idx=0);
defaulting is still choosing.
| call | assumption | needs |
|---|---|---|
cholesky |
recursive contemporaneous ordering | ordering (optional) |
bq |
one shock has all the long-run effect on one variable | permanent_var_idx, cumulate |
sign_restrictions |
IRF signs at named horizons | restrictions |
sign_zero |
signs and exact zeros on the impact matrix | zero_constraints, sign_constraints |
narrative_sign_svar |
signs plus the sign or dominance of a shock on named dates | sign_matrix, restrictions |
proxy |
an observable correlated with one shock, orthogonal to the rest | instrument_series |
identify_maxshare |
the shock is the one that explains most of a variable's FEV | target_idx, max_fev_at |
hetero |
shock variances shift across two known regimes | regime_indicator |
magmav_svar |
shock variances shift at dates the estimator finds itself | — |
non_gaussian_svar |
at most one shock is Gaussian | — |
gk_robust_bands |
signs, but reported as an identified set | restrictions, shock_idx |
Two naming traps. puremacro.var.identify.cholesky is the function
cholesky_svar, not the submodule — the package __init__ shadows it, as it
does for bq, proxy, hetero, maxshare and sign_zero. And the
identified shock is column 0 of B in proxy (whatever shock_target_idx
you passed), in identify_maxshare and in sign_restrictions, and column 0 is
the most non-Gaussian shock in non_gaussian_svar. The two schemes that let you
name the shock instead are narrative_sign_svar (per restriction, and per
column of sign_matrix) and gk_robust_bands (shock_idx).
Cholesky
from puremacro.var.identify import cholesky, compute_chol_shocks
res = cholesky(Y, p=4, horizon=20, ordering=[2, 0, 1], n_boot=500, ci=0.9, seed=0)
t_index, eps = compute_chol_shocks(Y, p=4, ordering=[2, 0, 1]) # (T-p,), (T-p, n)
ordering permutes the variables before the factorisation and then un-permutes
both axes on the way out, so irf_point[h, i, j] always means "variable i
responding to the shock attached to variable j" in your original column order,
no matter what ordering you passed. Reorder the variables and the numbers change;
the axis labels do not. compute_chol_shocks follows the same rule and returns
the shocks themselves — ε_t = L⁻¹ u_t — with t_index giving the row offsets
into Y so you can map them back to dates for an LP-IV or a narrative check.
Blanchard–Quah
bq(Y, p=..., horizon=..., permanent_var_idx=0, cumulate=True) imposes that
only the shock in column permanent_var_idx has a non-zero long-run effect on
the level of that variable: (I − ΣA_i)⁻¹ B is lower triangular once that
variable is moved first. Shock permanent_var_idx is the permanent one (supply,
technology); the others have no long-run effect on it.
Which responses come back cumulated depends on how each column entered, and
cumulate= says so. The VAR must be stationary, so a variable with a unit
root enters in first differences and a stationary one may enter in levels.
Cumulating the response of a differenced variable over the horizon gives the
response of its level. Cumulating a variable that is already in levels gives
the running sum of its response, which converges to ((I − ΣA_i)⁻¹ B)[i, :]
instead of returning to zero — a permanent effect the identification rules
out.
| specification | columns of Y |
cumulate |
irf_point[h, i, j] |
|---|---|---|---|
| Blanchard–Quah (1989) | [100·Δlog GNP, unemployment rate] |
[0] |
row 0: % deviation of the level of GNP; row 1: deviation of the unemployment rate, in points |
| Galí (1999), hours in differences | [100·Δlog productivity, 100·Δlog hours] |
True (default) |
both rows: % deviation of the level |
| hours in levels (the CEV critique) | [100·Δlog productivity, log hours] |
[0] |
row 1: deviation of log hours, not cumulated |
| raw responses of the columns as entered | any | False |
no row cumulated |
Blanchard and Quah define X = (ΔY, U)′ with Y the log of GNP and U the
level of the unemployment rate, and obtain the effect on the level of Y
after k periods as the partial sum of the ΔY responses (NBER WP 2737, 1988,
p. 3); their figures plot the log of output and the unemployment rate itself.
So for their system:
import numpy as np
from puremacro.var.identify import bq
# gnp, urate: your quarterly series of real GNP and the unemployment rate (%)
Y = np.column_stack([100 * np.diff(np.log(gnp)), urate[1:]]) # (Δ log GNP, U)
res = bq(Y, p=4, horizon=40, permanent_var_idx=0, cumulate=[0])
res.irf_point[:, 0, 1] # log GNP (%) after the demand shock: returns to 0
res.irf_point[:, 1, 1] # unemployment rate (points) after the demand shock: returns to 0
res.irf_point[:, 0, 0] # log GNP after the supply shock: the permanent effect
cumulate=True is the default and the behaviour of every earlier release (all
rows cumulated), which is right when every column is differenced; it is wrong
for BQ's own (Δy, u) system, where it turns the unemployment response into a
spurious permanent effect. A list of indices, or a boolean mask of length n,
cumulates only those rows. The same transformation is applied to every
bootstrap draw before the percentiles are taken, so irf_lower and
irf_upper are bands of the object you asked for. Do not cumsum a cumulated
row again, and do not np.diff cumulated bands to recover a levels variable —
the difference of two quantiles is not a quantile; pass cumulate= instead.
If (I − ΣA_i) is singular the call raises a LinAlgError naming the
near-unit-root, rather than returning an inverse of a singular matrix.
Sign restrictions, and the four different formats
The sign-restriction family does not share one argument format, because the schemes restrict different objects. This is the table to check before typing:
| call | argument | format | restricts |
|---|---|---|---|
sign_restrictions |
restrictions |
{h: [s₀ … s_{n−1}]} |
responses to shock column 0 at horizon h |
gk_robust_bands |
restrictions |
{h: [s₀ … s_{n−1}]} |
responses to shock shock_idx |
narrative_sign_svar |
sign_matrix |
{h: S}, S shape (n, n) or (n,); a bare array means {0: S} |
S[i, j] restricts variable i to shock j; an (n,) vector restricts shock 0 |
sign_zero |
sign_constraints, zero_constraints |
{(i, j): ±1}, [(i, j)] |
entries of B0 — impact only, horizon 0 |
0 always means unrestricted. sign_restrictions draws n_draws=2000
Haar-uniform rotations by default and raises RuntimeError if none is
admissible; n_accepted / n_draws is in the summary() and is the number to
look at before believing the bands, since a 1% acceptance rate means the median
is a median over twenty draws.
sign_zero is the odd one out in the whole subpackage: it takes A_list and
Sigma rather than Y, it returns only B0 and Q (no IRFs), and a failed
search does not raise — inspect success, which is False with B0=None when
no draw satisfied everything within n_draws=1000.
Narrative sign restrictions
Antolín-Díaz & Rubio-Ramírez (2018), with Ludvigson–Ma–Ng magnitude bounds
attached. Three kinds of NarrativeRestriction:
kind |
says | extra fields |
|---|---|---|
"shock_sign" |
shock j had this sign on this date (AD-RR Type I) |
sign |
"hd_dominance" |
shock j was the most ("most", Type II) or overwhelmingly the most ("overwhelming", Type III) important driver of variable i over a window |
variable, window, dominance |
"shock_bound" |
the absolute magnitude of shock j on this date lies in a stated range (LMN 2021) |
min_magnitude, max_magnitude |
from puremacro.var.identify import narrative_sign_svar, NarrativeRestriction
res = narrative_sign_svar(
Y, p=4, horizon=20,
sign_matrix={0: [+1, +1, 0]},
restrictions=[NarrativeRestriction(kind="shock_sign", date=100, shock=0, sign=+1)],
n_draws=2000, ci=0.9, seed=0,
)
res.ess, res.n_narrative_accepted, res.restriction_fail_counts
date is a row index into Y when dates=None, and a timestamp located in
dates otherwise. A bare (date, shock, sign) tuple is accepted as Type I
shorthand, and a puremacro.narrative.NarrativeEvent is adapted to one using its
announcement date and sign, targeting shock 0.
Two diagnostics carry the weight of the method. restriction_fail_counts gives,
per restriction, how many of the traditionally-accepted draws it killed — a
count of zero means the restriction did nothing and a count equal to
n_traditional_accepted means it killed everything. And ess is the Kish
effective sample size of the AD-RR importance weights: when ess is far below
n_narrative_accepted, a handful of draws are carrying the bands and the
percentiles are not describing what they appear to describe. Reduced-form
parameters are fixed at OLS unless bayes_draws=True, which samples them from
the conjugate Normal-Inverse-Wishart posterior instead.
Proxy / external instrument
from puremacro.var.identify import proxy
res = proxy(Y, p=4, horizon=20, instrument_series=z, n_boot=500, ci=0.9, seed=0)
res.first_stage_F # Olea-Pflueger effective F
res.B[:, 0] # the identified impact vector
The instrument is aligned to the residuals by its last T − p observations
(z[-T_eff:]) and demeaned. Under the Mertens–Ravn / Stock–Watson assumptions
Π = Cov(u, z)/Var(z) is the impact column up to scale, and the
normalisation that pins the scale is b₁′ Σ⁻¹ b₁ = 1, which follows from
Σ = BB′. So the impact vector is
computed through a Cholesky solve rather than an explicit inverse. This is worth
spelling out because the obvious-looking alternative, (Σ Π)/sqrt(Π′ Σ Π), is
what this package shipped from 0.92.0 to 1.8.0 and is wrong: it is proportional
to Σ b₁ rather than to b₁, correct only when Σ is a multiple of the
identity. It still satisfies b′ Σ⁻¹ b = 1 exactly and the SVD completion still
returned BB′ = Σ to machine precision, so no internal consistency check could
see it. On a DGP with true b₁ = [1, 0.8, −0.5] it converged, at T = 400,000, to
[0.919, 1.049, −0.608]. If you published a proxy-SVAR IRF from a release
before this fix, re-run it.
shock_target_idx does not change the identification — it only selects which
residual column the first-stage F is computed on. The identified shock is
column 0 of B regardless.
first_stage_F is the Olea–Pflueger effective F, not a Wald F. summary()
labels it WEAK at or below 23.0 — the published 5%-worst-case-bias cutoff for
a single instrument is 23.1, rounded down in the flag — and below it the
bootstrap bands understate the uncertainty and weak-IV-robust inference is the
honest answer.
Max-share
from puremacro.var.identify import identify_maxshare, maxshare, news_maxshare
res = identify_maxshare(Y, p=4, target_idx=0, max_fev_at=40,
horizon=40, n_bootstrap=500, ci=0.68, seed=0)
The optimisation is closed form: the impact vector is chol(Σ) q where q is
the top eigenvector of a symmetric PSD matrix, so there is no search and no
starting value. max_fev_at accumulates horizons 0 … max_fev_at − 1, so
max_fev_at=1 is impact and a large value is the Barsky–Sims news shock.
news_maxshare(A_list, Sigma, target_var=..., horizon=40) is the same engine
with that horizon baked in.
Note the two argument-name breaks from the rest of the subpackage:
identify_maxshare uses n_bootstrap (not n_boot) and defaults to ci=0.68
(not 0.9).
MaxShareResult.B is not a valid structural impact matrix beyond column 0.
_complete_B orthonormalises the remaining columns in the Euclidean metric, so
B B′ ≠ Σ. Column 0 is exactly right — it is chol(Σ) q — but fevd and
fev_share_at_target are normalised by B B′ rather than by Σ and are
therefore not the forecast-error variance decomposition of the estimated system.
At max_fev_at=1 the max-share shock is by construction the whole impact
forecast error of the target variable, so the share is analytically exactly 1.0
— and fev_share_at_target reports visibly less. The low-level helper does not
have the problem: it returns chol(Σ) Q with a genuinely orthogonal Q, so
B B′ = Σ to machine precision and the impact share comes back at 1.0. For
shares, go through it:
from puremacro.var import fit_var, fevd
from puremacro.var.identify import maxshare
r = fit_var(Y, p=4)
B = maxshare(r.A_list, r.Sigma, target_var=0, horizon=7) # == max_fev_at=8
share = fevd(r.A_list, B, 24)[7, 0, 0]
maxshare(..., horizon=h) and identify_maxshare(..., max_fev_at=h+1) return
the same impact column up to its sign — one calls numpy.linalg.eigh and the
other scipy.linalg.eigh, and the sign of an eigenvector is arbitrary — but the
off-by-one is real.
Heteroskedasticity: Rigobon and Magnusson–Mavroeidis
hetero (Rigobon 2003) takes a binary regime_indicator of length T or
T − p — it slices off the first p itself when you give it T — and
eigendecomposes L₀⁻¹ Σ₁ L₀⁻ᵀ. variance_ratios are those eigenvalues, the
across-regime variance ratio of each shock; a ratio near 1 is a shock the regime
break does not separate, and the column is then only as identified as the
neighbouring eigenvalues are distinct. Each regime needs at least n + 1
observations or the call raises. Bands come from a moving-block bootstrap with
block_len defaulting to round((T − p)^(1/3)); n_boot=0 returns
lower=upper=None.
magmav_svar (Magnusson–Mavroeidis 2014) finds the break dates itself by
sup-Wald plus BIC over {0, 1, 2, 3, 4} breaks. When BIC picks zero breaks
there is no heteroskedasticity to identify from, and the function warns and
falls back to a plain Cholesky rather than failing — so check k_breaks and
eu before reading the IRFs. eu == (1, 1) means the impact-matrix optimiser
converged; (0, 0) means either zero breaks or non-convergence. It is a
convergence proxy, not the paper's existence-and-uniqueness test, and the
dataclass docstring says so.
Non-Gaussian
non_gaussian_svar(Y, p=..., horizon=..., seed=0) runs FastICA on the whitened
residuals (Lanne–Meitz–Saikkonen 2017) and orders the columns by descending
absolute excess kurtosis, so the most non-Gaussian shock is column 0. It ships
its own falsification: lr_test is a likelihood-ratio test against the Gaussian
baseline (keys stat, df, p_value) and consistency_check reports
max_abs_diff of B0 B0′ against Σ_u. A p_value that does not reject
Gaussianity means the identifying assumption is not in the data and the column
ordering is noise. It takes no bootstrap arguments and returns no bands.
Giacomini–Kitagawa robust bands
Standard sign-restriction output is a posterior under a uniform prior on Q,
not the identified set. gk_robust_bands separates the two: for each of
n_var_draws bootstrap VAR estimates it maps the set with n_q_per_draw Haar
draws and takes the min, max and within-set median, then aggregates across VAR
draws. irf_lower and irf_upper are the set endpoints, not a confidence
interval, and are (H+1, n) — one named shock. n_accepted_per_draw is the
per-VAR-draw acceptance count; VAR draws where nothing was admissible become
NaN rows and drop out of the nanpercentile.
n_var_draws=1 skips VAR uncertainty and gives the point identified set.
gk_robust_bands_from_gibbs takes posterior draws instead of the bootstrap, and
the shapes line up with minnesota_gibbs exactly:
from puremacro.var import minnesota_gibbs
from puremacro.var.identify import gk_robust_bands_from_gibbs
post = minnesota_gibbs(df, 4, n_draws=1000, burn=500) # A_draws (D, p, n, n)
res = gk_robust_bands_from_gibbs(post["A_draws"], post["Sigma_draws"],
horizon=20, restrictions={0: [1, 1, 0]},
shock_idx=0, n_q_per_draw=300, ci=0.9)
The result dataclasses
All frozen; all but HeteroResult carry a summary(). The central estimate is
not called the same thing everywhere, because it is not the same thing
everywhere — a median over rotation draws is not a point estimate:
| function | class | central | bands | notes |
|---|---|---|---|---|
cholesky |
CholeskySVARResult |
irf_point |
irf_lower, irf_upper |
n_boot, n_fail, ci |
bq |
BQSVARResult |
irf_point |
irf_lower, irf_upper |
rows in cumulate= are cumulated (default: all) |
sign_restrictions |
SignRestrictionResult |
irf_median |
irf_lower, irf_upper |
n_draws, n_accepted |
narrative_sign_svar |
NarrativeSignSVARResult |
irf_median (weighted) |
irf_lower, irf_upper |
weights, ess, restriction_fail_counts |
proxy |
ProxySVARResult |
irf_point |
irf_lower, irf_upper |
B, first_stage_F |
hetero |
HeteroResult |
irfs (= point) |
lower, upper (or None) |
B, variance_ratios, fevd |
identify_maxshare |
MaxShareResult |
irfs |
irf_lower, irf_upper (or None) |
B, q, fevd, fev_share_at_target |
magmav_svar |
MagMavSVARResult |
irf_point |
irf_lower, irf_upper |
variance_change_dates, k_breaks, eu |
non_gaussian_svar |
NonGaussianSVARResult |
irf |
— | B0, Q, kurtosis, lr_test |
sign_zero |
SignZeroResult |
— | — | success, B0, Q, n_draws_used |
gk_robust_bands |
GKRobustBandsResult |
irf_median |
irf_lower, irf_upper |
all (H+1, n) |
mean_group_svar |
PanelSVARResult |
irf_mean |
irf_lower, irf_upper |
country_irfs is (N, H+1, n, n) |
favar |
FAVARResult |
irf_panel |
irf_lower_panel, irf_upper_panel |
DataFrames, (H+1, N) |
Everything not marked otherwise is (H+1, n, n). HeteroResult lives in
identify/hetero.py, not identify/_results.py, and carries irfs and point
as two names for the same array.
Bootstrap bands, and what happens to a draw that fails
A bootstrap draw of a VAR can produce a Σ_b that is not positive definite, or
a long-run matrix that is singular, or an optimiser that will not converge.
There is no correct answer to what to do with such a draw, and the schemes here
do not all do the same thing. Three conventions are in use, and only one of them
tells you it happened.
| scheme | resampling | a failed draw |
|---|---|---|
cholesky, bq, identify_maxshare |
recursive residual | dropped; warns above 5%; raises if all fail |
magmav_svar |
regime-preserving residual | dropped; warns above 5%; NaN bands if all fail |
proxy |
Rademacher wild (residuals and proxy re-signed together) | dropped; warning above 5% failures; n_fail reported |
hetero |
moving block | replaced by the point IRF, silently |
favar |
recursive residual | replaced by the point IRF, silently |
gk_robust_bands |
recursive residual | NaN row, excluded by nanpercentile |
bootstrap_bands |
your choice | NaN draw, excluded by nanpercentile |
The drop-and-warn path is the one to trust, and it is stricter than it looks. A
bootstrap Σ_b is rejected when the smallest Cholesky pivot falls below 1e-4
times the largest — roughly cond(Σ_b) > 1e8 — which is far stricter than
puremacro._linalg.safe_cholesky's general threshold of 1e-7 (cond > 1e14).
The reason is stated in cholesky.py and is worth repeating: an estimator that
returns wide bands is honest, one that returns narrow bands built on
ill-conditioned draws is silent garbage. LAPACK's potrf does not raise on an
ultra-degenerate Σ_b; it returns NaN diagonals, so those are caught explicitly
too. n_fail is on the result, and summary() prints it as a rate.
Where the failed draw is replaced by the point estimate instead, n_fail does
not exist and cannot: the bands look fine and are too narrow by however many
draws failed.
bootstrap_bands — bands for an impact matrix of your own
from puremacro.var import bootstrap_bands
from puremacro.var.identify.cholesky import cholesky_factor
out = bootstrap_bands(Y, 4, lambda A_list, Sigma: cholesky_factor(Sigma), 20,
n_boot=500, alpha=0.10, method="recursive",
band="pointwise")
out["point"], out["lower"], out["upper"], out["draws"]
identify_fn(A_list, Sigma, **id_kwargs) -> B0. method is "recursive",
"wild" (Rademacher) or "block" (block length round(T^(1/3))).
bias_correct=True runs the Kilian (1998) pilot stage — n_pilot=100 draws to
estimate the finite-sample bias of the coefficients, subtracted and then shrunk
in steps of 0.01 until the corrected companion has max |eig| < 0.999.
Two things about this function differ from the rest of the page and will bite:
- It takes
alpha, notci.alpha=0.10is a 90% band. Everything else inpuremacro.vartakesci=0.9for the same thing. out["point"]is the bootstrap median, not the OLS estimate. It isnanpercentile(draws, 50). If you want the point estimate, compute it yourself fromfit_varand youridentify_fn.
band="sup-t" returns simultaneous bands instead (Montiel Olea &
Plagborg-Møller 2019), computed per (response, shock) pair across horizons,
and adds band and crit_value (an (n, n) array of per-pair critical values,
NaN where every horizon was degenerate) to the returned dict. Horizons whose
draws are exactly degenerate — the structural zeros a Cholesky impact matrix has
in every draw — collapse to the point rather than studentising 0/0. It raises
if fewer than 20 draws came back fully finite, since a sup-t critical value
estimated from a handful of draws is not a critical value. The band is centred
on the bootstrap median, a documented simplification of the paper's Algorithm 2,
which centres on the original-sample estimate.
FAVAR
Bernanke, Boivin & Eliasz (2005), for when the three variables that fit in a VAR
are not the three variables you care about. Principal components summarise a wide
informational panel, the VAR is run on [policy, factors], and the responses are
projected back onto every series in the panel through the loadings.
from puremacro.var import favar
res = favar(panel_df, policy_series, n_factors=3, p=2, horizon=20,
n_boot=200, ci=0.90, seed=42)
res.irf_panel["unemployment"] # (H+1,) response in the series' own units
res.explained_variance_ratio # (K,) share of panel variance per factor
print(res.summary())
panel_data is (T, N) — a DataFrame keeps its column names, an ndarray gets
Var_1 … Var_N. The panel is standardised, factors are extracted by SVD and
scaled by sqrt(T), then orthogonalised against the policy series; the state
vector is Z = [Y, F̃] of dimension 1 + K and the policy shock is shock 0 of a
Cholesky on Z, which is what makes ordering the policy variable first the
identifying assumption. irf_panel is returned in the original units of each
series — the normalised responses are multiplied back by each column's standard
deviation — so the columns are directly readable and directly plottable.
Three limits worth knowing before quoting a FAVAR number:
- Demean the policy series before passing it. Both the factor
orthogonalisation and the loading regression run through the origin with no
intercept, while the panel has been standardised to mean zero, so the mean you
leave in changes both. The VAR on
Zdoes carry a constant, so the policy variable's own response is unaffected to machine precision — which is exactly why this is easy to miss. On a synthetic 20-series panel, adding a constant 4.0 to a unit-standard-deviation policy series moved panel impulse responses by as much as the largest response itself and leftirf_policyunchanged. - Failed bootstrap draws are replaced by the point IRF, so
irf_lower_panelandirf_upper_panelare too narrow by however many draws failed, with nothing recording how many. - The factors are principal components of the whole panel, not of the
slow-moving block. BBE's identification uses the slow/fast split; this is the
simpler variant, and the orthogonalisation against
Yis doing all the work of separating the policy instrument from the factors.
Panel VAR
Canova–Ciccarelli mean-group: one SVAR per country, IRFs averaged across countries, bands from the cross-country distribution rather than from any bootstrap. The bands therefore describe heterogeneity across countries, not sampling uncertainty within one — which is the right object for "does this shock do the same thing everywhere" and the wrong one for "is this response significant".
from puremacro.var.identify import mean_group_svar
res = mean_group_svar({"USA": Y_us, "DEU": Y_de, "ESP": Y_es},
p=4, horizon=20, identification="cholesky", ci=0.9)
res.irf_mean # (H+1, n, n)
res.country_irfs # (N, H+1, n, n)
res.country_ids # ('DEU', 'ESP', 'USA') — SORTED, not insertion order
country_ids is tuple(sorted(panel_data)). country_irfs[i] is the
country at country_ids[i], which is not the order you passed the dict in.
Always index through country_ids.
The canonical version supports identification="cholesky" and "bq" only, and
raises ValueError naming the alternatives for anything else — schemes that
need per-country bootstrap kwargs are better run per country against their own
module. All countries must share n; T_i may differ.
There is a second, older mean_group_svar in puremacro.var.panel. It accepts
"proxy" and "rigobon" as well, substitutes a zero IRF with a warning when a
country fails instead of raising, returns country_ids as a list in insertion
order, and names its band fields irf_lo / irf_hi rather than
irf_lower / irf_upper. Two things its docstrings claim and its code does not:
the dataclass says (n, n, H+1) where the code returns (H+1, n, n) like
everything else, and the module header advertises a "maxshare" scheme the
dispatch table has no entry for (it raises KeyError). Prefer the identify
version unless you need proxy or rigobon across a panel.
peak_summary and peak_distribution turn a dict of per-country
{"point", "lo", "hi", "n_obs"} into one row per country — peak horizon and
value, irf_4 / irf_8 / irf_16, and the accumulated response at h=16 — for
forest plots across countries. peak_lo and peak_hi are the band bounds at
the peak horizon and are explicitly not a confidence interval on the argmax,
which has a non-standard sampling distribution.
Not on this page
puremacro.var also ships bvar (Minnesota prior as dummy observations, NIW
Gibbs), vecm, tvp (time-varying parameters), regime (Markov-switching,
threshold, TVECM, generalised IRFs) and diagnostics (granger_causality,
block_exogeneity). Check their shapes rather than assuming: regime.girf
shocks one equation at a time, so girf_by_regime is (K, S, H+1, n) — K
starting regimes by S shock sizes — and girf_pooled is (S, H+1, n), not
(H+1, n, n).
The Minnesota BVAR has two posteriors, and they differ in what the
cross-variable factor λ₂ can do. minnesota_posterior solves one
Theil–Goldberger mixed regression per equation, so λ₂ (default 0.5) is
honoured; it returns the posterior mean only. minnesota_gibbs,
minnesota_optimal_lambda and the marginal likelihood behind it use the
conjugate Normal–inverse-Wishart prior, built from the dummy observations of
Bańbura, Giannone and Reichlin (2010, eq. 5): own first lags centred on 1,
prior sd λ₁ σ_i / (k^λ₃ σ_j). Its covariance Ψ ⊗ Ω₀ gives every equation
the same prior shape, so it can only encode λ₂ = 1 — the paper imposes the
prior "under the condition that ϑ = 1" (ECB WP 966, p. 11). These functions
default to λ₂ = 1 and warn, then use 1, for any other value; at λ₂ = 1 the
exact Gibbs posterior mean (A_mean) equals minnesota_posterior's. In
puremacro 4.3.0 and earlier their dummy block centred the own first lag of
every variable except the last one on λ₂ (0.5 by default) instead of 1, so the
posterior depended on the column order.