Model card — Quantile regression & growth-at-risk¶
quantile_regression · quantile_lp · growth_at_risk
A conditional mean answers "what do we expect?"; a conditional quantile answers "how bad (or good) can it plausibly get?". This family estimates the whole conditional distribution one quantile at a time by minimizing the Koenker-Bassett check loss, and its headline application is growth-at-risk: the Adrian-Boyarchenko-Giannone (2019) finding that financial conditions move the lower tail of future GDP growth far more than they move its center or upper tail. The center of a distribution can look calm while its downside quietly deteriorates — quantile methods are how you see that happening.
The estimation problem at quantile level tau throughout is
solved by iteratively reweighted least squares (IRLS), with Powell
kernel-sandwich standard errors (Epanechnikov kernel, Hall-Sheather
bandwidth) — the same estimator and covariance as statsmodels QuantReg
at its defaults.
quantile_regression — linear quantile regression¶
What it estimates. The coefficient vector b(tau) of the tau-th
conditional quantile Q_tau(y | x) = x' b(tau), at one or many quantile levels
in a single call. Under a location-scale DGP y = x'b + (x'g) * eps the slope
genuinely varies across tau — b(tau) = b + g * F_eps^{-1}(tau) — and that
variation is the signal: a slope that is negative at tau = 0.1 and positive
at tau = 0.9 means the regressor widens the distribution, not (only) shifts
it.
Assumptions. Correct linear specification of each conditional quantile;
independent-enough observations for the Powell sandwich to be a sensible
covariance (it is a heteroskedasticity-robust estimator, not a HAC one — for
serially correlated LP-style designs use quantile_lp, which is built for
that shape of problem). The conditional density at the quantile must be
positive (no flat spots of the distribution at the quantile being estimated).
When to use (and when not). Use it whenever the tails are the question —
risk measures, heterogeneous effects across the outcome distribution,
distributional shifts — or when outliers make the mean fragile (the median
regression at tau = 0.5 is the robust-location workhorse). Do not use it to
answer a mean question: b(0.5) is the median effect, not the average effect.
Do not read fitted quantiles at wildly extrapolated x; and for extreme tau
(0.01, 0.99) remember that the effective sample is only the observations near
that tail.
Key arguments and defaults (and why). x is your full design matrix —
include the constant column yourself (statsmodels exog convention; the
function adds nothing). taus defaults to [0.05, 0.25, 0.5, 0.75, 0.95], a
standard risk-profile grid. se="robust" (the Powell kernel sandwich) is the
only shipped flavor, matching the statsmodels default.
How to read the output. Per-tau lists, aligned with taus: params[i] is
the coefficient vector at taus[i] (column order of x), with bse and
tvalues alongside; bandwidth and sparsity are the Hall-Sheather bandwidth
and estimated sparsity (inverse density at the quantile) that feed the
sandwich; iterations counts IRLS steps per tau and converged is a single
bool over all of them. Plot params[:][k] against taus — a flat line says
"pure location shift", a sloped one says "the regressor changes the shape".
Failure modes. Forgetting the constant column (slopes then absorb the
intercept and every quantile is wrong). Quantile crossing: separate per-tau
fits can produce fitted quantiles that violate Q_0.25 > Q_0.5 at some x —
harmless for coefficient interpretation, fatal for reading off a distribution;
growth_at_risk handles this with rearrangement, here you must check
yourself. Sparse tails: at extreme tau the sparsity estimate is noisy and
bse with it. converged=False (very rare at the shared 1e-6 tolerance)
means the IRLS hit its iteration cap — treat the affected tau with suspicion.
Validated against. statsmodels QuantReg(y, x).fit(q=tau) at all
defaults: params to 1e-6 (the shared IRLS stopping tolerance) and
bse/bandwidth/sparsity to 1e-6 relative, across multiple taus and
designs (fixtures/tsecon-quantile.json,
generated by
fixtures/generate_tsecon-quantile_fixtures.py).
References. Koenker & Bassett (1978, Econometrica 46:33-50); Powell (1991, kernel-sandwich covariance); Hall & Sheather (1988, bandwidth); Koenker (2005, Quantile Regression).
The DGP below is location-scale by construction — the true slope is
0.5 + 0.75 * z_tau — so the estimated slopes should fan out across taus,
flipping sign in the lower tail:
import numpy as np, tsecon
rng = np.random.default_rng(0)
n = 1000
x1 = rng.uniform(0.0, 2.0, n) # a positive "risk factor"
eps = rng.standard_normal(n)
y = 1.0 + 0.5 * x1 + (0.5 + 0.75 * x1) * eps # scale grows with x1
X = np.column_stack([np.ones(n), x1]) # YOUR explicit constant
fit = tsecon.quantile_regression(y, X, taus=[0.1, 0.5, 0.9])
z = {0.1: -1.2816, 0.5: 0.0, 0.9: 1.2816} # standard-normal quantiles
for i, tau in enumerate(fit["taus"]):
b, se = fit["params"][i], fit["bse"][i]
print(f"tau={tau:.1f}: slope={b[1]:+.3f} (se {se[1]:.3f}) "
f"true {0.5 + 0.75 * z[tau]:+.3f}")
print("converged:", fit["converged"])
# tau=0.1: slope=-0.504 (se 0.097) true -0.461
# tau=0.5: slope=+0.526 (se 0.086) true +0.500
# tau=0.9: slope=+1.353 (se 0.104) true +1.461
# converged: True
The same regressor lowers the 10th percentile and raises the 90th — a pure mean regression (slope 0.5 throughout) would have missed the entire story.
quantile_lp — quantile local projections¶
What it estimates. Impulse responses of the conditional quantiles of y
to an identified shock: per horizon h and quantile tau, the coefficient on
shock_t in a check-loss regression of y_{t+h} on
[shock_t, const, p lags of y and shock] — the same design conventions as
lp, with the impulse coefficient in design column 0.
Where lp gives you one response path (the mean), this gives you a fan: how
the shock moves the bad tail, the center, and the good tail of the future
outcome separately.
Assumptions. An identified, exogenous-conditional-on-controls shock,
exactly as for lp; correct linear quantile specification per horizon. The
standard errors are the Powell kernel sandwich per (tau, horizon) —
heteroskedasticity-robust but not HAC, so with overlapping multi-horizon
residuals treat far-horizon bands as approximate and lean on the lag controls
to soak up serial dependence.
When to use (and when not). Use it when the question is distributional —
"does a financial shock raise downside risk to growth?", "do oil shocks fatten
the inflation tail?" — or when you suspect the mean IRF averages over
heterogeneous tail responses. If only the average response matters, use lp:
it is better-understood, has lag-augmented/HAC inference, and its bands are
tighter. For the specific "conditional quantiles of future growth given
current conditions" question, growth_at_risk below is the purpose-built
tool (conditions enter as regressors, not as an identified shock).
Key arguments and defaults. taus defaults to [0.1, 0.5, 0.9] (a
downside/center/upside fan); horizons=12, n_lag_controls=4 as in lp. The
constant is added internally here (lp design conventions), unlike
quantile_regression.
How to read the output. irf[i][h] is the response of the taus[i]
quantile at horizon h, with se[i][h] alongside. Read vertically: the gap
between the tau=0.1 and tau=0.9 paths at each horizon is the shock's
effect on dispersion. Coinciding paths mean a pure location shift and lp
would have sufficed.
Failure modes. All of lp's (non-identified shock, too few lag controls)
plus the quantile-specific ones: crossing between adjacent tau paths at some
horizons (fans are estimated independently per tau), and noisy extreme-tau
paths in short samples — every quantile LP at tau = 0.05 on 150 observations
is an exercise in optimism.
Validated against. statsmodels QuantReg per (tau, horizon) on the
identical stacked design at 1e-6
(fixtures/tsecon-quantile.json).
References. Koenker & Bassett (1978); Jordà (2005); Adrian, Boyarchenko & Giannone (2019) for the risk-fan reading; Powell (1991).
The DGP gives the shock a location effect that decays at 0.8 and a
contemporaneous scale effect (a positive shock compresses the noise), so at
h = 0 the lower-tail response should exceed the upper-tail response, with
the fan closing at longer horizons:
import numpy as np, tsecon
rng = np.random.default_rng(0)
n = 500
shock = rng.standard_normal(n)
eps = rng.standard_normal(n)
y = np.zeros(n)
for t in range(n):
mean_t = sum(0.8 ** h * shock[t - h] for h in range(min(t, 20) + 1))
y[t] = mean_t + np.exp(-0.4 * shock[t]) * eps[t] # + shock -> less dispersion
q = tsecon.quantile_lp(y, shock, taus=[0.1, 0.5, 0.9], horizons=8, n_lag_controls=2)
irf = np.asarray(q["irf"])
print(" h=0 h=1 h=2 h=3")
for i, tau in enumerate(q["taus"]):
print(f"tau={tau:.1f}: " + " ".join(f"{irf[i, h]:+.3f}" for h in range(4)))
# h=0 h=1 h=2 h=3
# tau=0.1: +1.384 +0.665 +0.710 +0.449
# tau=0.5: +0.996 +0.817 +0.540 +0.338
# tau=0.9: +0.632 +0.811 +0.733 +0.575
At impact the 10th-percentile response (1.384) is more than double the 90th-percentile response (0.632) around a median response of about 1 — the shock shifts and tightens the distribution, and the fan closes toward the common 0.8-decay path once the contemporaneous scale effect washes out.
growth_at_risk — conditional quantiles of future growth (ABG 2019)¶
What it estimates. The Adrian-Boyarchenko-Giannone (2019 AER) object:
conditional quantiles of the horizon-ahead outcome given today's
conditioning variables,
fitted by quantile regression at each tau and evaluated at every
observation t — canonically: quantiles of GDP growth h quarters ahead
given the NFCI and current growth. The signature finding this reproduces: the
lower quantiles of future growth are volatile and strongly driven by financial
conditions, while the upper quantiles stay roughly flat — downside risk moves,
upside doesn't.
Assumptions. Linear conditional quantiles in [const, conditions, y_t]
(the constant is added internally here); enough time-series stationarity for
the per-tau QuantReg fits and their Powell sandwiches to be meaningful. This
is a predictive/descriptive object — no causal identification is claimed for
the conditioning variables.
When to use (and when not). Use it for risk dashboards and financial-
stability monitoring: "what is the 5% worst-case for growth next year, given
today's financial conditions?" — and for testing whether some indicator moves
downside risk asymmetrically. Do not read the coefficients causally (tight
financial conditions may proxy for shocks already underway); do not use it for
horizon = 0 nowcasts (horizon >= 1 is required, by design); and if your
question is about the response to an identified shock rather than the
predictive content of observed conditions, use quantile_lp.
Key arguments and defaults (and why). conditions is T × k (one column
per indicator). horizon = 1 and taus = [0.05, 0.25, 0.5, 0.75, 0.95]
mirror the ABG quarterly setup; taus must be strictly increasing.
rearrange = True applies the Chernozhukov-Fernandez-Val-Galichon (2010)
monotone rearrangement — a simple sort across tau at each t — which
repairs any quantile crossing without harming (weakly improving) estimation
quality; leave it on unless you specifically want to inspect the raw paths.
How to read the output. params/bse are per-tau coefficient rows in the
order [const, conditions..., y_t] — the asymmetry finding is read straight
off the conditions column: a large negative slope at tau = 0.05 shrinking to
nothing at tau = 0.95. fitted is the (n_taus × T) matrix of conditional
quantiles after rearrangement (fitted_raw before); crossing flags whether
the raw paths crossed anywhere, i.e. whether rearrangement actually did
something. current is the risk read at the latest observation — the row of
fitted at t = T-1, the number a policymaker would quote today. Note the
timing: fitted[:, t] conditions on date-t information and describes
y_{t+horizon}.
Failure modes. Reading current without checking what the conditions were
at T-1 (a benign read in calm conditions says nothing about the slope of
risk). Overfitting with many conditioning variables — ABG's power comes from
one financial-conditions index, not twenty indicators. Long horizons with
overlapping observations make the sandwich SEs optimistic. And extreme taus on
short macro samples (200 quarterly observations put only ~10 of them below the
5% quantile) carry honest, wide uncertainty — report bse.
Validated against. statsmodels QuantReg per tau on the identical
[const, conditions, y_t] design, plus a numpy sort for the rearrangement, at
1e-6 (fixtures/tsecon-quantile.json).
References. Adrian, Boyarchenko & Giannone (2019, American Economic Review 109:1263-1289); Chernozhukov, Fernandez-Val & Galichon (2010, Econometrica 78:1093-1125, rearrangement); Koenker & Bassett (1978).
The DGP builds in the ABG mechanism: tighter financial conditions f lower
the conditional mean of growth and raise its variance, so the lower tail
should move roughly -0.5 - 0.35·1.645 ≈ -1.08 per unit of f while the
upper tail stays near flat:
import numpy as np, tsecon
rng = np.random.default_rng(1)
n = 400
# f = financial-conditions index (higher = tighter), AR(1)
f = np.zeros(n)
e = rng.standard_normal(n)
for t in range(1, n):
f[t] = 0.8 * f[t - 1] + 0.6 * e[t]
# growth: tighter conditions lower the mean AND raise the variance
y = np.zeros(n)
eps = rng.standard_normal(n)
for t in range(1, n):
y[t] = 2.0 + 0.3 * y[t - 1] - 0.5 * f[t - 1] + np.exp(0.35 * f[t - 1]) * eps[t]
g = tsecon.growth_at_risk(y, f.reshape(-1, 1), horizon=1) # taus default 5/25/50/75/95
params = np.asarray(g["params"]) # rows = taus, cols = [const, f, y_t]
bse = np.asarray(g["bse"])
print("tau const f-slope y-slope")
for i, tau in enumerate(g["taus"]):
print(f"{tau:.2f} {params[i,0]:+.3f} {params[i,1]:+.3f} ({bse[i,1]:.3f}) {params[i,2]:+.3f}")
print("crossing (raw paths):", g["crossing"])
fitted = np.asarray(g["fitted"]) # (n_taus, T) conditional quantiles, rearranged
tight = int(np.argmax(f)) # date with the tightest conditions in sample
loose = int(np.argmin(f))
print(f"\nnext-period growth quantiles, tightest (f={f[tight]:+.2f}) vs "
f"loosest (f={f[loose]:+.2f}) conditions:")
for i, lbl in [(0, "Q05"), (2, "Q50"), (4, "Q95")]:
print(f" {lbl}: {fitted[i, tight]:+.2f} vs {fitted[i, loose]:+.2f} "
f"(moved {fitted[i, loose] - fitted[i, tight]:+.2f})")
cur = np.asarray(g["current"]) # the last-observation risk read
print(f"\ncurrent read (f={f[-1]:+.2f}): "
+ " ".join(f"Q{int(t*100)}={c:+.2f}" for t, c in zip(g["taus"], cur)))
# tau const f-slope y-slope
# 0.05 -0.064 -0.771 (0.098) +0.449
# 0.25 +0.879 -0.759 (0.090) +0.415
# 0.50 +1.641 -0.491 (0.088) +0.416
# 0.75 +2.526 -0.284 (0.106) +0.365
# 0.95 +3.671 +0.113 (0.161) +0.333
# crossing (raw paths): False
#
# next-period growth quantiles, tightest (f=+1.88) vs loosest (f=-2.01) conditions:
# Q05: -0.19 vs +2.85 (moved +3.04)
# Q50: +1.94 vs +3.89 (moved +1.95)
# Q95: +4.86 vs +4.45 (moved -0.41)
#
# current read (f=-0.16): Q5=+1.59 Q25=+2.42 Q50=+3.14 Q75=+3.81 Q95=+4.78
The f-slope column is the ABG picture: -0.771 at the 5th percentile fading
monotonically to +0.113 (insignificant) at the 95th. Moving from the
loosest to the tightest conditions in the sample drags the 5% quantile down by
3 points — from comfortable growth to outright contraction — while the 95%
quantile barely notices. The distribution of future growth doesn't shift under
financial stress; its left tail stretches.