Skip to content

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 lags, a series too short for the horizon, or a treatment that adjust_for names too. The outcome may be adjusted for, as by default: its level at t is a control for its level at t + h.

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).