chc.irf¶
Stability tier: evolving.
Dynamic causal effects: the impulse-response / carryover kernel for control.
CHC's one-step estimate_control_effect gives the scalar d x_next/d u; a controller planning
over a horizon needs the whole d x_{t+h}/d u_t, h = 0..H -- how an intervention
propagates over time (the impulse response, or an MMM adstock carryover kernel). This is Jorda
local projections: one regression per horizon of the h-step-ahead outcome on the treatment
plus an adjustment set, so conditioning on the state/confounders blocks the backdoor path exactly as
the one-step estimate does. The dynamic sibling of chc.causal.estimate_control_effect.
This is a causal-effect estimator, not a sequence model.
DelayEstimate
dataclass
¶
How long a causal effect takes to arrive, with an interval rather than a bare point.
lag is in steps and delay in the caller's time unit (lag * dt); lo and hi
bound the delay, not the lag. peak_response keeps its sign, so an effect that arrives
negative is visible as such even though the peak is located on |beta|.
censored marks a peak sitting on an end of the horizon, where the delay is not identified:
the true peak may lie anywhere beyond the window, so the number is a bound on the delay and must
not be read as an estimate of it.
local_projection_irf(data, horizon, treatment='u', outcome='x', adjust_for=('x',), lags=0)
¶
Jorda local-projections IRF beta_h = d outcome_{t+h}/d treatment_t, h = 0..horizon.
data holds aligned trajectory columns. For each h the h-step-ahead outcome is
regressed on [treatment_t, *adjust_for_t] and an intercept; the treatment coefficient is the
horizon-h dynamic causal effect. With adjust_for the backdoor path is blocked (effect
identified); omit the confounder and it stays confounded. Returns the length H + 1 IRF.
lags > 0 adds lags own-lags of the outcome and of the treatment to every projection --
lag augmentation (Montiel Olea & Plagborg-Moller 2021). It leaves the estimand alone and buys
inference: without it, a coefficient's confidence interval loses coverage on persistent data
and at long horizons, and a plant with a delay is persistent by construction. The default stays
0 so existing callers get the estimates they had.
Raises:
| Type | Description |
|---|---|
ValueError
|
on negative |
peak_lag(irf, refine=False)
¶
Location of the IRF's largest-magnitude response, and whether it is censored.
Returns (lag, censored). Magnitude, not signed value, because a dynamic effect that
overshoots and returns changes sign; the arrival time is where the response is largest,
whichever way it points.
refine fits a parabola through the peak and its two neighbours and returns the vertex, so
the answer is not quantised to the sampling step. It is off by default because a causal
impulse response is one-sided -- zero before the effect arrives, decaying after -- and so is
maximally asymmetric at exactly the point being located. On a geometrically decaying response
with ratio phi the vertex lands at lag + phi/(2 * (2 - phi)): 0.41 of a step late at
phi = 0.9, a bias that does not shrink with the sample and swamps the bootstrap width.
Turn it on when the response is genuinely smooth across the grid -- a delay that is not an
integer multiple of dt smears across neighbouring bins, and then the integer argmax can only
quantise. Even there the vertex is exact only for a symmetric peak: a lag split (1 - f, f)
across two bins puts it at f/(4 - 6f), so f = 1/2 comes back exactly and f = 1/3
comes back as 1/6. It moves the right way and continuously; it is not unbiased.
A peak at either end of the horizon is returned unrefined and flagged censored: there is no
neighbour to fit through, and the response is still rising at the edge, so the true peak may lie
outside the window. censored is not a test for an identified peak -- an effect that never
arrives within the horizon leaves noise, whose argmax sits wherever noise puts it. What flags
that case is the width of the interval delay_estimate returns, not this flag.
The fit is local to three points, so a plateau wider than that is read from its leading edge:
two tied bins give the midpoint (the wanted answer for a delay halfway between samples), three
give the midpoint of the first two. Every closed form here is derived in
validation/delay_estimate_bias.mac.
delay_estimate(data, horizon, dt=1.0, treatment='u', outcome='x', adjust_for=('x',), lags=0, refine=False, n_resamples=400, level=0.95, seed=0)
¶
Estimate how long a causal effect takes to propagate, with a bootstrap interval.
This is the delay half of a delay causal graph: an edge already carries a lag from
chc.discovery.LaggedGraph, but a lag with no uncertainty is a claim nobody can check.
The estimand is the transport lag of the treatment-to-outcome path -- how long an
intervention takes to show its largest effect -- read off local_projection_irf with
adjust_for blocking the backdoor path, so it is the delay of the causal effect and not of
a correlation. That is the tau of a plant whose delay sits on the actuation path;
chc.delay.DelayedDynamics carries its delay on the state-feedback path instead, so its
tau is a different quantity and this estimate does not identify it.
Set lags > 0 if you intend to read coefficients off irf; it does not change this
interval. Lag augmentation fixes the asymptotic variance of a single local-projection
coefficient under persistence (Montiel Olea & Plagborg-Moller 2021), and a delayed plant is
persistent by construction -- but the peak is a location statistic, invariant to a common
rescaling of the IRF, and the block bootstrap below already handles the serial dependence
non-parametrically. Measured over 40 replications at phi in {0.98, 0.999, 1.0}, coverage of
a nominal 95% interval was 0.95-1.00 with and without augmentation and the widths agreed to 3%.
So the default matches local_projection_irf, and the option is offered rather than
imposed.
The interval is a moving-block percentile bootstrap over the aligned projection rows, refit
and re-peaked on every resample, with block length lags + horizon + 1. Blocks, because a
local projection's rows overlap by construction -- rows closer together than the window share
observations -- so resampling rows independently would destroy the dependence that sets the
width and report an interval far too narrow. That window length is also exactly the separation
at which two rows stop sharing anything, which is what makes it the natural block.
A flat or noise-dominated IRF is not rejected and not smoothed over: the peak wanders across the
horizon under resampling and the interval comes back wide, which is the honest report. A sharply
identified lag goes the other way and returns lo == hi: the resampled peak never moves off
its bin, which is the correct interval for a delay that is an exact multiple of dt.
refine is forwarded to peak_lag; read its warning before turning it on.
innovations(series, order)
¶
Prewhitened residual: fit an AR(order) via Levinson, return series - AR-prediction.
The Box-Jenkins innovation -- the unpredictable part left after the autoregressive structure
is removed. Prewhitening both series before cross-correlating de-biases it under autocorrelation
-- a classical complement to the MCI test in chc.independence.
structured_irf(data, horizon, order=4, treatment='u', outcome='x', adjust_for=('x',))
¶
Structured IRF: AR dynamics from Levinson + the one-step impact, propagated (transfer fn).
Fits the outcome's AR(order) via Levinson on the biased (PSD) autocorrelation -- the dynamic
propagation -- and the one-step treatment impact (confounding-adjusted h = 1 local
projection), then propagates g_0 = 0, g_1 = impact, g_h = sum_i a_i g_{h-i}. Agrees
with the local-projections IRF but makes the AR structure explicit (reflection coeffs).
irf_control_sequence(irf, target)
¶
Feed-forward control to track a target output, by deconvolving the impulse response.
The output is the causal convolution of the control with the IRF (x = G u, G lower-tri
Toeplitz of the response), so achieving a target trajectory x* is the deconvolution
u = G^{-1} x* -- solved with the Toeplitz machinery. This makes the whole dynamic effect
actionable: it accounts for carryover, where a one-step controller that inverts only g_1
over-actuates on a delayed plant (steady-state error sum_h g_h / g_1).