Quickstart#
This example creates a small panel, estimates the response of y to shock,
and plots the impulse response.
Create example data#
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n_units = 50
n_periods = 30
data = pd.DataFrame(
{
"unit": np.repeat(np.arange(n_units), n_periods),
"time": np.tile(np.arange(n_periods), n_units),
}
)
data["shock"] = rng.normal(size=len(data))
data["control"] = rng.normal(size=len(data))
unit_effect = rng.normal(size=n_units)[data["unit"]]
data["y"] = (
0.8 * data["shock"]
+ 0.3 * data["control"]
+ unit_effect
+ rng.normal(size=len(data))
)
Every (unit, time) pair must be unique. Use a finite, integer-valued period
index for time, with the index advancing by one per underlying period (for
example, 0, 1, 2, ...). Panels may be unbalanced and therefore may contain
gaps in that index. Horizons are exact time leads: horizon h uses the outcome
at time + h, so a missing target period is not replaced by the next observed
row. For monthly or quarterly data, construct a sequential period index rather
than using decimal labels such as 2020.1 or 2020.2.
Fit local projections#
from fastlp import LocalProjection
lp = LocalProjection(
horizons=8,
covariance="cluster",
sample="common",
)
lp.fit(
data,
outcome="y",
shock="shock",
controls=["control"],
outcome_lags=2,
shock_lags=2,
control_lags=2,
unit="unit",
time="time",
fixed_effects=["unit", "time"],
cluster="unit",
)
A scalar lag value includes every lag from one through that value.
sample="common" uses the same anchor observations at every horizon.
sample="per_horizon" instead retains all observations available for each
horizon, so sample size may vary across the response path. common is
encouraged for better performance.
Inspect results#
print(lp.summary())
results = lp.to_frame()
print(results.query("coefficient == 'shock'"))
to_frame() returns one row per horizon and coefficient. It includes the
estimate, standard error, pointwise confidence interval, response type, sample
policy, and number of observations.
Useful fitted attributes include:
coef_,stderr_,covariance_, andconf_int_feature_names_in_andshock_names_in_n_obs_by_horizon_,n_units_, andn_periods_demeaning_diagnostics_,linear_algebra_diagnostics_, andcovariance_diagnostics_
Plot the response#
ax = lp.plot_irf(
"shock",
title="Response of y to shock",
ylabel="Outcome units",
)
ax.figure.savefig("irf.png", dpi=150, bbox_inches="tight")
The returned object is a Matplotlib Axes, so normal Matplotlib customization
remains available. Set show = False to use the Axes object in your own Figure.
Interactions, grouped responses, and progress#
Import the term helpers together with the estimator:
from fastlp import LocalProjection, sugar as s
lp = LocalProjection(horizons=8, covariance="cluster")
lp.fit(
data,
outcome="y",
shock=s.interact(
"event", "treated",
by=s.C("prior_exposure_group", levels=["0", "1", "2+"]),
name="event_by_experience",
),
controls=[s.interact("income", "urban", "post")],
unit="unit", time="time",
fixed_effects=[s.fe("region", "time"), "unit"],
cluster="unit",
progress=True,
)
ax = lp.plot_irf("event_by_experience")
print(lp.irf_frame("event_by_experience"))
s.fe() treats its keys as one categorical fixed-effect partition.
s.interact() multiplies numeric columns. A shock interaction includes the
lower-order terms needed for interpretation; a control interaction adds only
the specified product. Generated nuisance terms absorbed by fixed effects
appear in lp.absorbed_terms_. A requested shock or control with no within
variation still raises an error. Keep the grouping variable based on
information known before the shock if the goal is to compare responses by
prior experience.
For a continuous moderator, use by="moderator" and give the values to plot:
shock = s.interact("event", "treated", by="prior_count", name="adaptation")
# After fitting with shock=shock:
lp.plot_irf("adaptation", at=[0, 1, 3])
The displayed response at each value combines the base and interaction
coefficients; its interval uses their full covariance. With categorical
moderation, the plot has one line per category, and irf_frame() includes
counts for each retained category and for nonzero shock observations.
progress=True prints stage and elapsed-time updates. For application UIs,
progress_callback=callback receives immutable ProgressEvent objects at
stage and horizon-batch boundaries. Long native operations show an elapsed-time
heartbeat in the built-in display; the estimator does not change its numerical
batch size to make the display update more frequently.
Choosing inference#
Set covariance when constructing LocalProjection:
Value |
Use |
|---|---|
|
Classical OLS covariance |
|
Heteroskedasticity-robust covariance |
|
One-way or multiway clustered covariance |
|
Within-unit HAC/Newey–West covariance |
|
Driscoll–Kraay covariance |
Clustered inference requires cluster= in fit(). HAC and Driscoll–Kraay
require integral numeric time labels. fastLP warns when a clustering dimension
has fewer than 50 groups by default; that warning identifies a limitation of
cluster asymptotics.
Memory budgeting#
LocalProjection(memory_budget="1GB") checks a conservative allocation plan
before panel preparation. The plan includes selected-frame copies, lag columns,
lead alignment, sample masks, retained results, and numerical workspaces. The
remaining allowance determines horizon batch size; tight budgets can also
stream HAC lag pairs. Budgets below the planned minimum raise ValueError
instead of silently being exceeded. Inspect lp.memory_diagnostics_ after a
successful fit for the planned minimum, peak, and selected batch size.
This is a bound on the allocation plan, not a hard process-memory cap.
Caller-owned input, Python/allocator overhead and private library workspaces
are excluded; conservative estimates may reject a fit that would happen to
use less memory. Lazy Polars inputs require an additional aggregate pass for
sizing. Leave memory_budget=None to retain the unbudgeted execution path.
Other input backends#
Polars DataFrame and LazyFrame inputs use the same column-based interface.
A NumPy array requires one unique name for every column:
array = data[["y", "shock", "control", "unit", "time"]].to_numpy()
lp.fit(
array,
column_names=["y", "shock", "control", "unit", "time"],
outcome="y",
shock="shock",
controls=["control"],
unit="unit",
time="time",
)