Impulse Response (IRF) Matching
This page describes the impulse response matching workflow and the elements the user needs to adjust for a successful run. For a more technical description of the procedure, see Christiano et al. (2010). An application of the procedure can be found in Bayer, C., Born, B., & Luetticke, R. (2023).
Description and Procedure
What is IRF Matching?
Beyond pure calibration, one way to “bring the model to the data” is to match model-implied impulse responses to empirical impulse responses estimated outside the model.
- Model IRFs: generated by the structural HANK model.
- Empirical IRFs: typically estimated using data-driven methods (e.g., local projections).
The goal is to align the model’s dynamic responses to those seen in the data for a chosen set of variables and shocks over a fixed horizon.
Below we describe how to perform IRF matching using this package.
Step 1 — Supply Empirical IRFs and Choices
The user must prepare a file containing empirical IRFs over:
- Variables: responses of aggregates such as
:G, :Y, :B, :I, :LPXA(these correspond to Government Spending, Output, Bonds, Investment, and Ex-ante liquidity premium). - Shocks: typically one structural shock, e.g., a government spending shock
:Gshockor a monetary policy shock:Rshock. - Horizon: a number of periods (e.g.,
15).
These IRFs should be estimated externally (e.g., using local projections or a VAR) and saved in a .csv.
An example CSV is provided in the repository. Follow its structure exactly. It can be found in examples/baseline/Data with the name irf_data_0706_inclPC.csv. We provide a description of the .csv at the bottom of this notebook.
Save your .csv file in the corresponding examples/<model>/Data folder for the model of interest.
Optional: In principle, these estimates and standard errors in the .csv are choices and do not need to be estimated. They are merely "numbers" the optimizer will target. If you wish to weigh the targets differently, for example, weight the h=2 response of a certain IRF, you need to define, in addition, a "data_to_target" file.
This file has the same structure as the example .csv, but it is meant to be passed on to the optimizer. If no "data_to_target" file is specified, the data file above is used for the optimization.
For estimates you wish not to target, assign a large standard error (e.g., 10000) in the "data_to_target" file. This will endogenously induce our optimizer to not target the estimate, as it is seen as "noisy".
However, when we plot your provided estimates and standard errors, we will take them from the "data_to_plot".
Step 2 — Configure the Code
Two files are important for the estimation: input_parameters.jl (where EstimationSettings and ModelParameters are defined) and the mainboard main_irfmatching.jl (provided as an example for IRF matching runs).
API: IRF Matching Functions
The IRF matching workflow is implemented in the Estimation submodule. These are the primary user-facing and helper functions:
BASEforHANK.Estimation.irfmatch — Function
irfmatch(par, IRFtargets, weights, shocks_selected, isstate, indexes_sel_vars, priors, sr, lr, m_par, e_set)User-facing entry point for Bayesian IRF matching in the style of Christiano et al.
This wrapper remaps the candidate parameter vector par to the support of the specified priors and delegates to irfmatch_backend to evaluate the IRF distance and prior contribution.
Arguments
par::AbstractVector: candidate parameters (unconstrained space allowed).IRFtargets::AbstractArray: target IRFs with shape(T, n_vars, n_shocks).weights::AbstractArray: weights (same shape asIRFtargets) applied to squared errors.shocks_selected::Vector{Symbol}: names of shocks to perturb.isstate::BitVector: mask of selected variables that are states (end-of-period reporting).indexes_sel_vars::AbstractVector{Int}: indexes of selected variables in[k; d].priors::AbstractVector: prior distributions for parameters (Distributions.jl).sr,lr: steady and linear results (supply indexes and linear solution).m_par: model parameters struct (baseline).e_set: estimation settings (e.g., horizon underirf_matching_dict).
Returns
llike_irf::Float64: negative IRF distance (higher is better).prior_like::Float64: prior log-density forpar.post_like::Float64: objective combining IRF fit and prior (scaled prior).alarm::Bool: true if prior violated or model solution failed.
BASEforHANK.Estimation.irfmatch_backend — Function
irfmatch_backend(par, IRFtargets, weights, shocks_selected, isstate, indexes_sel_vars, priors, sr, lr, m_par, e_set)Core IRF-matching routine. Evaluates the weighted squared distance between model-implied IRFs and IRFtargets and combines it with the prior log-density.
Workflow:
- Evaluate prior via
prioreval; if violated, set a large distance and raisealarm. - Reconstruct
m_parfromparand solve the reduced linear system usingLinearSolution_reduced_system(aggregate-only update). - Compute IRFs for
shocks_selectedandindexes_sel_varsfor the horizone_set.irf_matching_dict["irf_horizon"]. - IRF distance is 0.5 × sum of weighted squared deviations; objective is distance plus prior.
Returns
llike_irf::Float64: negative distance (−IRFdist).prior_like::Float64: prior log-density.post_like::Float64:−IRFdist + prior_scale * prior_like.alarm::Bool: true if prior violated or solution failed.
BASEforHANK.Estimation.compute_irfs_for_matching — Function
compute_irfs_for_matching(sr, State2Control, LOMstate, m_par, shocks_selected, indexes_sel_vars, isstate, irf_horizon)Compute model-implied impulse responses for selected variables and shocks using the linear solution (State2Control, LOMstate).
Conventions:
- For state variables (
isstate = true), IRFs are reported at end-of-period (t = 1…H). - For control variables, IRFs are reported at the beginning-of-period (aligned with states one period earlier).
Arguments
sr: steady results (providesindexes_rfor shock positions).State2Control::AbstractMatrix:gxmapping states to controls.LOMstate::AbstractMatrix:hxmapping states to next states.m_par: model parameters (provides shock scalesσ_<shock>).shocks_selected::Vector{Symbol}: shocks to apply (one at a time).indexes_sel_vars::AbstractVector{Int}: indexes of variables for which to record IRFs.isstate::BitVector: mask ofindexes_sel_varsthat are states.irf_horizon::Integer: number of periods to report.
Returns
IRFsout::Array{Float64,3}of size(irf_horizon, n_vars, n_shocks).
BASEforHANK.Estimation.remap_params! — Function
remap_params!(θ, priors; ϵ = 1e-9)Project a parameter vector θ onto the support of the provided priors in-place.
Mappings when θ[i] lies outside support(priors[i]):
Gamma/InverseGamma:θ[i] = softplus(θ[i]) + ϵ(ensures > 0).Beta:θ[i] = ϵ + (1 − 2ϵ) / (1 + exp(−θ[i]))(maps to (ϵ, 1 − ϵ)).Normal: unbounded; left unchanged.
Arguments
θ::AbstractVector: parameter vector to be remapped (modified in-place).priors::AbstractVector: vector of distributions compatible with Distributions.jl.ϵ::Real: small positive slack to avoid boundary issues (default1e-9).
Returns
- The modified vector
θ(returned for convenience).
BASEforHANK.Estimation.softplus — Function
softplus(x)Numerically stable mapping to the positive reals: softplus(x) = log(1 + exp(x)). Used to map unconstrained parameters to the support of Gamma/InverseGamma priors.
2a) Update EstimationSettings in input_parameters.jl
Create or modify an instance of EstimationSettings inside main_irfmatching.jl. Example:
e_set = EstimationSettings(;
irf_matching = true,
observed_vars_input = [
:G, # Government Spending
:Y, # Output
:B, # Bonds
:I, # Investment
:LPXA, # Ex-ante liquidity premium
],
max_iter_mode = 2000,
f_tol = 1.0e-6,
x_tol = 1.0e-8,
ndraws = 500,
burnin = 7000,
mhscale = 0.45,
compute_hessian = true,
irf_matching_dict = Dict(
"irfs_to_plot" => paths["src_example"] * "/Data/irf_data_0706_inclPC.csv",
"irf_horizon" => 15,
"scale_responses_by" => :B,
"irfs_to_target" => paths["src_example"] * "/Data/irf_noisy_0706_inclPC.csv", # OPTIONAL
),
);We emphasize the importance of observed_vars_input, which specifies the names of the aggregates as it appears on the .csv. For the estimation to run, these data names must coincide with the model names. Otherwise, the unmatched names will be skipped. Please have a look at the model variable names, which can be found in input_aggregates_model.mod. Again, the names in the .csv must match these.
Alternatively, the user can use data_rename (also in EstimationSettings) to rename the data variables lazily to model variables e.g., data_rename = Dict(:pi => :π) renames :pi (in the data) to :π (the model name).
2b) Change parameter values in ModelParameters
In input_parameters.jl, under another struct, ModelParameters, one can find the shocks of interest and adjust their persistence and standard deviation accordingly. For example, if one were adjust the government spending shock, for a transitory shock, one can do:
ρ_Gshock::T = 1e-8 | "rho_Gshock" | L"\rho_D" | Beta(beta_pars(0.5, 0.2^2)...) | false # Pers. in structural deficit
σ_Gshock::T = 0.01 | "sigma_G" | L"\sigma_D" | InverseGamma(ig_pars(1.00, 0.2^2)...) | true # Std Gwhere we set the persistence of the shock to basically zero, with a tight prior for the standard deviation, centered around 1. The same could be done for monetary policy shocks,productivity shocks, etc.
2c) Point to your IRF file
Inside main_irfmatching.jl, you need to (1) specify the file that holds the empirical IRFs, (2) specify the horizon, and (3) a variable you wish to scale your respones by:
@set! e_set.irf_matching_dict = Dict(
"irfs_to_plot" => paths["src_example"] * "/Data/your_file_name_here.csv",
"irf_horizon" => 15,
"scale_responses_by" => :B,
"irfs_to_target" => paths["src_example"] * "/Data/targeted_file_here.csv", # OPTIONAL
)The first two items have already been explained. irf_matching_dict["scale_responses_by"] is a symbol that corresponds to a variable. As explained before, the symbol name for the variable has to correspond to a model name. All empirical IRFs are scaled by the maximum of this variable. This implies a peak empirical response of 1\% for irf_matching_dict["scale_responses_by"]. In this case, this means that :B will equal 1 for some h and less than 1 for all other h. If you wish not to scale the IRFs, just put nothing.
Again, the "irfs_to_target" is optional, in case you want to weight the estimates differently than the data in "irfs_to_plot".
Step 3 — Optimization
For the user, this step is the same with or without IRF matching. No coding adjustments need to be made. The code inherits the options specified from EstimationSettings above and runs the appropriate code under the hood.
With these adjustments, you can execute the estimation procedure, and the optimizer will find parameter values that minimize the distance between model estimates and data estimates, for your chosen variables and shocks. An MCMC step is then conducted to generate the posterior, from which we can sample from.
For convenience, you can also use the same mode finding and MCMC wrappers documented under Estimation to run IRF matching. While the objective differs (IRF distance vs. likelihood), the wrappers handle common tasks like prior evaluation, diagnostics, and smoother outputs when applicable.
It is important to keep track of the optimization trace. This informs you about the acceptance rate and the current posterior likelihood. If things look strange, some suggestions: (1) have a look at the mhscale, which scales the perturbations in the MCMC sampler, (2) compute the hessian by setting compute_hessian = true in EstimationSettings, (3) look at the standard errors of your estimator (precise estimates are given priority), or (4) change the number of runs in the optimization or number of draws in the MCMC sampler.
Step 4 — Computation of IRFs and Confidence Intervals
For the computation of IRFs, there is no special function. One can simply use compute_irfs to generate them.
Note: The example below uses the public compute_irfs from the PostEstimation module, which supports confidence intervals and plotting workflows. During IRF matching optimization, an internal fast version is used to evaluate model IRFs without interval estimation.
If you wish to estimate confidence intervals, then please read the text below. Otherwise, you can skip.
# Storing model objects
irf_interval_options = Dict(
"lr" => lr_mc,
"sr" => sr_mc,
"n_replic" => 300,
"percentile_bounds" => (0.05, 0.95),
"draws" => draws_raw,
"e_set" => e_set,
"m_par" => m_par_mc,
);
# Compute IRFs
IRFs_mc, _, IRF_lb, IRF_ub, _, _, IRFs_order = compute_irfs(
exovars,
lr_mc.State2Control,
lr_mc.LOMstate,
sr_mc.XSS,
sr_mc.indexes_r;
init_val = stds_mc,
irf_interval_options = irf_interval_options,
);To estimate confidence intervals, we need to specify a dictionary irf_interval_options and pass it as an option in the function compute_irfs.
Since the MCMC sampler gives us draws of the posterior, specified by "draws" => draws_raw, we can generate a set of IRFs for each parameter draw. Each draw will generate probabilistically a different set of IRFs. As there are potentially thousands of draws that define our posterior, "n_replic" tells the function how many draws to use. In this case, we randomly draw 300 samples from the posterior. It is from these draws that we estimate the confidence intervals, of bandwidth "percentile_bounds".
"lr", "sr", "m_par" can be from the mode or from the mcmc step. "e_set" is as before.
Step 5 - Plotting of IRFs
To plot model-implied IRFs along side your empirically identified IRFs, one can use the usual function plot_irfs. This overlays empirical IRFs with the model’s IRF, for the different models (if more than one is specified in IRFs_to_plot), as well as produce the model-implied IRFs separately for the select_variables.
select_variables = irf_interval_options["e_set"].observed_vars_input
nice_var_names =
["Government Spending", "Output", "Bonds", "Investment", "Liquidity Premium"]
shocks_to_plot = [(:Gshock, "Structural deficit")]
vars_to_plot = [(select_variables[i], nice_var_names[i]) for i in eachindex(select_variables)]
IRFs_to_plot = [(IRFs_mc, "Mode")]
plot_irfs(
shocks_to_plot,
vars_to_plot,
IRFs_to_plot,
IRFs_order,
sr_mc.indexes_r;
intervals = [(IRF_lb, IRF_ub)],
e_set = e_set,
plot_data_irfs = true,
show_fig = false,
save_fig = true,
path = paths["bld_example"] * "/IRFs",
yscale = "standard",
style_options = (lw = 2, color = [:blue, :red], linestyle = [:solid, :dash]),
)
If one is interested in the output of the IRF matching, one needs to set plot_data_irfs to true and set e_set. This will plot the IRFs estimated externally and pass all of the relevant settings specified before.
All other things specified (e.g., shocks_to_plot, vars_to_plot, and IRFs_to_plot) are the same as without IRF matching –- simply specify whichever variables/shocks/models are relevant for you. If one specifies a shock that was not used for IRF matching, only the model-implied IRFs will be generated.
If one wishes to have intervals around point estimates, intervals needs to be specified. The vector of tuples must be in the same order as IRFs_to_plot. Each tuple is defined as the lower and upper bounds on the IRFs, output that is generated from compute_irfs.
Overall Checklist for Running IRF Matching
- Prepare Empirical IRFs: Run your external estimation and create a CSV in the correct format.
- Configure estimation: In
main_irfmatching.jl, generate an instance ofEstimationSettings, turning onirf_matching, settingobserved_vars_input, specifying your.csvfile and optimizer settings. - Double Check: the names of your aggregates in the
.csvfile and that they match model names.
Description of the CSV file containing the external estimates
To generate your own file of external estimates, follow the file structure of the data file provided. Here is a small example:
| time | pointdum | shock | Y | C | π |
|---|---|---|---|---|---|
| 1 | 1 | Gshock | 0.20 | 0.10 | 0.05 |
| 1 | 0 | Gshock | 0.05 | 0.04 | 0.02 |
| 2 | 1 | Gshock | 0.15 | 0.08 | 0.04 |
| 2 | 0 | Gshock | 0.04 | 0.03 | 0.02 |
The file has 3 mandatory columns and N additional columns for the N aggregate variables you wish to match on.
- Column 1: time: this column is equal to the horizon of the impulse response, where 1 is equal to the impact response.
- Column 2: pointdum: this is short for point estimate dummy. It is equal to 1 for the point estimate, equal to 0 for the standard error, which is derived from the asymptotic Normal distribution, hence why only 1 entry is permitted.
- Column 3: shock: this is the shock the estimates correspond to. The name of the shock must follow the model shock name, so please make sure to find its counterpart. They would be found in
input_parameters.jl. - Remaining columns: carry estimates and errors for some variable.
Minor Details
At the moment,
pointdum, short for point dummy, shows that the rows alternate between estimate and error. It does not have to be made like this. You can have estimates first, then stack the error thereafter. We use thefilter()function to extract the appropriate information.Again, the remaining
Ncolumns' names have to correspond to the respective model names. The sheet provided has names that satisfies this condition. Otherwise, one could usedata_renameinside ofEstimationSettingsto map data names to model names.