localproj 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
localproj/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Composable local projections for Python."""
2
+
3
+ from .bootstrap import BootstrapResult
4
+ from .core import lp
5
+ from .estimators.bayesian import BayesianSULPResult
6
+ from .plotting import plot_irf
7
+ from .result import LPResult
8
+ from .transforms import add_lags, lag, lead
9
+
10
+ __all__ = [
11
+ "BayesianSULPResult",
12
+ "BootstrapResult",
13
+ "LPResult",
14
+ "add_lags",
15
+ "lag",
16
+ "lead",
17
+ "lp",
18
+ "plot_irf",
19
+ ]
localproj/_fitted.py ADDED
@@ -0,0 +1,203 @@
1
+ """Immutable, adapter-neutral fitted state used inside :mod:`localproj`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+
13
+ def _immutable_array(
14
+ value: Any,
15
+ *,
16
+ dtype: type[float] | type[int],
17
+ ndim: int,
18
+ ) -> np.ndarray:
19
+ """Copy values onto immutable bytes-backed storage."""
20
+
21
+ copied = np.array(value, dtype=dtype, copy=True, order="C")
22
+ if copied.ndim != ndim:
23
+ raise ValueError(f"expected a {ndim}-dimensional array, got {copied.ndim}")
24
+ return np.frombuffer(copied.tobytes(order="C"), dtype=copied.dtype).reshape(copied.shape)
25
+
26
+
27
+ def _readonly_array(value: Any, *, ndim: int) -> np.ndarray:
28
+ return _immutable_array(value, dtype=float, ndim=ndim)
29
+
30
+
31
+ def _readonly_int_array(value: Any) -> np.ndarray:
32
+ return _immutable_array(value, dtype=int, ndim=1)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LinearFitState:
37
+ """Normalized linear-model state needed by downstream estimators and inference.
38
+
39
+ The state contains only arrays, names, and flags. It deliberately contains no backend
40
+ model object, so callers outside estimator modules never need adapter-specific knowledge.
41
+ """
42
+
43
+ design: np.ndarray
44
+ residuals: np.ndarray
45
+ coefficients: np.ndarray
46
+ coefficient_names: tuple[str, ...]
47
+ is_iv: bool = False
48
+ has_fixed_effects: bool = False
49
+ origin_order: np.ndarray | None = None
50
+ sequence: np.ndarray | None = None
51
+ demeaned_design: np.ndarray | None = None
52
+ demeaned_outcome: np.ndarray | None = None
53
+
54
+ def __post_init__(self) -> None:
55
+ design = _readonly_array(self.design, ndim=2)
56
+ residuals = _readonly_array(self.residuals, ndim=1)
57
+ coefficients = _readonly_array(self.coefficients, ndim=1)
58
+ names = tuple(str(name) for name in self.coefficient_names)
59
+ if design.shape[0] != len(residuals):
60
+ raise ValueError("linear design and residuals have inconsistent row counts")
61
+ if design.shape[1] != len(names) or len(coefficients) != len(names):
62
+ raise ValueError("linear design, coefficients, and coefficient names are inconsistent")
63
+ object.__setattr__(self, "design", design)
64
+ object.__setattr__(self, "residuals", residuals)
65
+ object.__setattr__(self, "coefficients", coefficients)
66
+ object.__setattr__(self, "coefficient_names", names)
67
+
68
+ if self.origin_order is not None:
69
+ origin_order = _readonly_int_array(self.origin_order)
70
+ if len(origin_order) != design.shape[0]:
71
+ raise ValueError("Origin Order is not aligned with the linear design")
72
+ object.__setattr__(self, "origin_order", origin_order)
73
+ if self.sequence is not None:
74
+ sequence = _readonly_int_array(self.sequence)
75
+ if len(sequence) != design.shape[0]:
76
+ raise ValueError("estimation sequence is not aligned with the linear design")
77
+ object.__setattr__(self, "sequence", sequence)
78
+ if self.demeaned_design is not None:
79
+ demeaned_design = _readonly_array(self.demeaned_design, ndim=2)
80
+ if demeaned_design.shape != design.shape:
81
+ raise ValueError("demeaned design is not aligned with the linear design")
82
+ object.__setattr__(self, "demeaned_design", demeaned_design)
83
+ if self.demeaned_outcome is not None:
84
+ demeaned_outcome = _readonly_array(self.demeaned_outcome, ndim=1)
85
+ if len(demeaned_outcome) != design.shape[0]:
86
+ raise ValueError("demeaned outcome is not aligned with the linear design")
87
+ object.__setattr__(self, "demeaned_outcome", demeaned_outcome)
88
+
89
+
90
+ @dataclass(frozen=True, init=False)
91
+ class HorizonFit:
92
+ """Immutable fitted state for one outcome/horizon projection."""
93
+
94
+ horizon: int
95
+ _params: pd.Series = field(repr=False)
96
+ _covariance: pd.DataFrame = field(repr=False)
97
+ _residuals: pd.Series = field(repr=False)
98
+ _fitted: pd.Series = field(repr=False)
99
+ nobs: int
100
+ df_resid: int
101
+ _design_columns: tuple[str, ...]
102
+ linear: LinearFitState | None = field(default=None, repr=False)
103
+
104
+ def __init__(
105
+ self,
106
+ *,
107
+ horizon: int,
108
+ params: pd.Series,
109
+ covariance: pd.DataFrame,
110
+ residuals: pd.Series,
111
+ fitted: pd.Series,
112
+ nobs: int,
113
+ df_resid: int,
114
+ design_columns: list[str] | tuple[str, ...],
115
+ linear: LinearFitState | None = None,
116
+ ) -> None:
117
+ params_copy = params.copy(deep=True)
118
+ params_copy.index = params_copy.index.astype(str)
119
+ covariance_copy = covariance.copy(deep=True)
120
+ covariance_copy.index = covariance_copy.index.astype(str)
121
+ covariance_copy.columns = covariance_copy.columns.astype(str)
122
+ names = tuple(str(name) for name in design_columns)
123
+ if list(covariance_copy.index) != list(covariance_copy.columns):
124
+ raise ValueError("fit covariance row and column labels must match")
125
+ if any(name not in covariance_copy.index for name in params_copy.index):
126
+ raise ValueError("fit covariance is missing parameter labels")
127
+
128
+ object.__setattr__(self, "horizon", int(horizon))
129
+ object.__setattr__(self, "_params", params_copy)
130
+ object.__setattr__(self, "_covariance", covariance_copy)
131
+ object.__setattr__(self, "_residuals", residuals.copy(deep=True))
132
+ object.__setattr__(self, "_fitted", fitted.copy(deep=True))
133
+ object.__setattr__(self, "nobs", int(nobs))
134
+ object.__setattr__(self, "df_resid", int(df_resid))
135
+ object.__setattr__(self, "_design_columns", names)
136
+ object.__setattr__(self, "linear", linear)
137
+
138
+ @property
139
+ def params(self) -> pd.Series:
140
+ """Return an independently owned coefficient vector."""
141
+
142
+ return self._params.copy(deep=True)
143
+
144
+ @property
145
+ def covariance(self) -> pd.DataFrame:
146
+ """Return an independently owned coefficient covariance matrix."""
147
+
148
+ return self._covariance.copy(deep=True)
149
+
150
+ @property
151
+ def residuals(self) -> pd.Series:
152
+ """Return independently owned residuals."""
153
+
154
+ return self._residuals.copy(deep=True)
155
+
156
+ @property
157
+ def fitted(self) -> pd.Series:
158
+ """Return independently owned fitted values."""
159
+
160
+ return self._fitted.copy(deep=True)
161
+
162
+ @property
163
+ def design_columns(self) -> list[str]:
164
+ """Return the fitted design-column names."""
165
+
166
+ return list(self._design_columns)
167
+
168
+ def with_params(self, params: pd.Series) -> HorizonFit:
169
+ """Return a replacement fit with a new coefficient vector.
170
+
171
+ The normalized linear state describes the original estimator coefficients and is
172
+ therefore omitted when a correction replaces them.
173
+ """
174
+
175
+ return HorizonFit(
176
+ horizon=self.horizon,
177
+ params=params,
178
+ covariance=self._covariance,
179
+ residuals=self._residuals,
180
+ fitted=self._fitted,
181
+ nobs=self.nobs,
182
+ df_resid=self.df_resid,
183
+ design_columns=self._design_columns,
184
+ linear=None,
185
+ )
186
+
187
+ def renamed(self, names: Mapping[str, str]) -> HorizonFit:
188
+ """Return a replacement fit with renamed coefficient labels."""
189
+
190
+ params = self._params.rename(index=names)
191
+ covariance = self._covariance.rename(index=names, columns=names)
192
+ design_columns = tuple(names.get(column, column) for column in self._design_columns)
193
+ return HorizonFit(
194
+ horizon=self.horizon,
195
+ params=params,
196
+ covariance=covariance,
197
+ residuals=self._residuals,
198
+ fitted=self._fitted,
199
+ nobs=self.nobs,
200
+ df_resid=self.df_resid,
201
+ design_columns=design_columns,
202
+ linear=self.linear,
203
+ )
localproj/bootstrap.py ADDED
@@ -0,0 +1,458 @@
1
+ """Bootstrap inference for local projections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from scipy.stats import norm
12
+
13
+ from .vcov import parse_vcov
14
+
15
+ if TYPE_CHECKING: # pragma: no cover
16
+ from ._fitted import HorizonFit
17
+ from .result import LPResult
18
+
19
+
20
+ _BOOTSTRAP_METHOD_ALIASES = {
21
+ "wild": "wild",
22
+ "wild-residual": "wild",
23
+ "wild_residual": "wild",
24
+ }
25
+
26
+ _WEIGHT_ALIASES = {
27
+ "rademacher": "rademacher",
28
+ "rad": "rademacher",
29
+ "mammen": "mammen",
30
+ "normal": "normal",
31
+ "gaussian": "normal",
32
+ }
33
+
34
+ _INTERVAL_ALIASES = {
35
+ "percentile": "percentile",
36
+ "pct": "percentile",
37
+ "basic": "basic",
38
+ "reverse-percentile": "basic",
39
+ "reverse_percentile": "basic",
40
+ "normal": "normal",
41
+ "standard": "normal",
42
+ }
43
+
44
+ _BAND_ALIASES = {
45
+ "pointwise": "pointwise",
46
+ "point-wise": "pointwise",
47
+ "point_wise": "pointwise",
48
+ "sup-t": "sup-t",
49
+ "supt": "sup-t",
50
+ "max-t": "sup-t",
51
+ "maxt": "sup-t",
52
+ "simultaneous": "sup-t",
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class BootstrapResult:
58
+ """Bootstrap draws and interval helpers for an :class:`~localproj.result.LPResult`.
59
+
60
+ The object stores long-form bootstrap coefficient draws for the requested shock paths.
61
+ Pointwise percentile/basic/normal intervals are available for horizon-specific samples;
62
+ bootstrap sup-t bands additionally require the original LP to have been estimated with
63
+ ``sample="common"`` so the same wild-bootstrap weights preserve cross-horizon
64
+ dependence.
65
+ """
66
+
67
+ parent: LPResult
68
+ estimates: pd.DataFrame
69
+ reps: int
70
+ method: str
71
+ weights: str
72
+ seed: int | None = None
73
+
74
+ @property
75
+ def horizons(self) -> list[int]:
76
+ """Horizons represented by the bootstrap draws."""
77
+
78
+ return self.parent.horizons
79
+
80
+ @property
81
+ def outcome_names(self) -> list[str]:
82
+ """Outcome names represented by the bootstrap draws."""
83
+
84
+ return self.parent.outcome_names
85
+
86
+ @property
87
+ def shock_names(self) -> list[str]:
88
+ """Shock names represented by the bootstrap draws."""
89
+
90
+ return self.parent.shock_names
91
+
92
+ @property
93
+ def vcov_type(self) -> str:
94
+ """Short label used by plotting/summary helpers."""
95
+
96
+ return f"bootstrap-{self.method}-{self.weights}"
97
+
98
+ @staticmethod
99
+ def _normalize_interval(interval: str) -> str:
100
+ value = interval.lower().replace("_", "-")
101
+ if value not in _INTERVAL_ALIASES:
102
+ raise ValueError("interval must be 'percentile', 'basic', or 'normal'")
103
+ return _INTERVAL_ALIASES[value]
104
+
105
+ @staticmethod
106
+ def _normalize_band(band: str) -> str:
107
+ value = band.lower().replace("_", "-")
108
+ if value not in _BAND_ALIASES:
109
+ raise ValueError("band must be 'pointwise' or 'sup-t'")
110
+ return _BAND_ALIASES[value]
111
+
112
+ def _selected_estimates(
113
+ self,
114
+ *,
115
+ outcome: str | Iterable[str] | None,
116
+ shock: str | Iterable[str] | None,
117
+ ) -> pd.DataFrame:
118
+ selected_outcomes = self.parent._select(
119
+ outcome, available=self.outcome_names, name="outcome"
120
+ )
121
+ selected_shocks = self.parent._select(shock, available=self.shock_names, name="shock")
122
+ table = self.estimates[
123
+ self.estimates["outcome"].isin(selected_outcomes)
124
+ & self.estimates["shock"].isin(selected_shocks)
125
+ ]
126
+ if table.empty:
127
+ raise ValueError("bootstrap selection produced no rows")
128
+ return table
129
+
130
+ def tidy(
131
+ self,
132
+ level: float = 0.95,
133
+ *,
134
+ interval: str = "percentile",
135
+ band: str = "pointwise",
136
+ outcome: str | Iterable[str] | None = None,
137
+ shock: str | Iterable[str] | None = None,
138
+ draws: int | None = None,
139
+ seed: int | None = None,
140
+ ) -> pd.DataFrame:
141
+ """Return bootstrap confidence intervals in long form.
142
+
143
+ Parameters ``draws`` and ``seed`` are accepted for compatibility with the generic
144
+ plotting helper; bootstrap draws are fixed when :meth:`LPResult.bootstrap` is
145
+ called, so they are ignored here.
146
+ """
147
+
148
+ del draws, seed
149
+ if not 0 < level < 1:
150
+ raise ValueError("level must be between 0 and 1")
151
+ interval_type = self._normalize_interval(interval)
152
+ band_type = self._normalize_band(band)
153
+ if band_type == "sup-t" and self.parent.metadata.get("sample") != "common":
154
+ raise NotImplementedError("bootstrap sup-t bands require sample='common'")
155
+
156
+ draws_table = self._selected_estimates(outcome=outcome, shock=shock)
157
+ original = self.parent.tidy(level=level)
158
+ original = original[
159
+ original["outcome"].isin(draws_table["outcome"].unique())
160
+ & original["shock"].isin(draws_table["shock"].unique())
161
+ ].copy()
162
+ original_indexed = original.set_index(["outcome", "shock", "horizon"]).rename(
163
+ columns={"std_error": "analytic_std_error"}
164
+ )
165
+
166
+ alpha = 1.0 - level
167
+ lower_q = alpha / 2.0
168
+ upper_q = 1.0 - alpha / 2.0
169
+ z_critical = float(norm.ppf(upper_q))
170
+
171
+ grouped = draws_table.groupby(["outcome", "shock", "horizon"], sort=False)["estimate"]
172
+ quantiles = grouped.quantile([lower_q, upper_q]).unstack()
173
+ quantiles.columns = ["boot_q_low", "boot_q_high"]
174
+ boot_std = grouped.std(ddof=1).rename("std_error")
175
+ summary = original_indexed.join(boot_std).join(quantiles)
176
+
177
+ critical_by_family: dict[tuple[str, str], float] = {}
178
+ if band_type == "sup-t":
179
+ for (outcome_name, shock_name), family_draws in draws_table.groupby(
180
+ ["outcome", "shock"], sort=False
181
+ ):
182
+ original_family = summary.loc[(outcome_name, shock_name)]
183
+ estimate_by_horizon = original_family["estimate"]
184
+ se_by_horizon = original_family["std_error"].replace(0.0, np.nan)
185
+ pivot = family_draws.pivot(index="rep", columns="horizon", values="estimate")
186
+ pivot = pivot.loc[:, self.horizons]
187
+ centered = pivot.sub(estimate_by_horizon, axis="columns")
188
+ studentized = centered.div(se_by_horizon, axis="columns").abs()
189
+ max_stat = studentized.max(axis=1).dropna()
190
+ if max_stat.empty:
191
+ raise ValueError(
192
+ "bootstrap sup-t bands require positive bootstrap standard errors"
193
+ )
194
+ critical_by_family[(str(outcome_name), str(shock_name))] = float(
195
+ max_stat.quantile(level)
196
+ )
197
+
198
+ rows: list[dict[str, Any]] = []
199
+ for key, values in summary.iterrows():
200
+ outcome_name, shock_name, horizon = key
201
+ estimate = float(values["estimate"])
202
+ std_error = float(values["std_error"])
203
+ if not np.isfinite(std_error) or std_error <= 0:
204
+ statistic = np.nan
205
+ p_value = np.nan
206
+ else:
207
+ statistic = estimate / std_error
208
+ p_value = 2.0 * (1.0 - norm.cdf(abs(statistic)))
209
+
210
+ if band_type == "sup-t":
211
+ critical = critical_by_family[(str(outcome_name), str(shock_name))]
212
+ conf_low = estimate - critical * std_error
213
+ conf_high = estimate + critical * std_error
214
+ interval_label = "bootstrap-sup-t"
215
+ elif interval_type == "percentile":
216
+ critical = np.nan
217
+ conf_low = float(values["boot_q_low"])
218
+ conf_high = float(values["boot_q_high"])
219
+ interval_label = interval_type
220
+ elif interval_type == "basic":
221
+ critical = np.nan
222
+ conf_low = 2.0 * estimate - float(values["boot_q_high"])
223
+ conf_high = 2.0 * estimate - float(values["boot_q_low"])
224
+ interval_label = interval_type
225
+ else:
226
+ critical = z_critical
227
+ conf_low = estimate - critical * std_error
228
+ conf_high = estimate + critical * std_error
229
+ interval_label = interval_type
230
+
231
+ rows.append(
232
+ {
233
+ "outcome": outcome_name,
234
+ "shock": shock_name,
235
+ "horizon": int(horizon),
236
+ "estimate": estimate,
237
+ "std_error": std_error,
238
+ "analytic_std_error": float(values["analytic_std_error"]),
239
+ "statistic": statistic,
240
+ "p_value": p_value,
241
+ "conf_low": conf_low,
242
+ "conf_high": conf_high,
243
+ "level": level,
244
+ "band": band_type,
245
+ "interval": interval_label,
246
+ "critical_value": critical,
247
+ "reps": self.reps,
248
+ "bootstrap_method": self.method,
249
+ "bootstrap_weights": self.weights,
250
+ "nobs": int(values["nobs"]),
251
+ "df_resid": int(values["df_resid"]),
252
+ "method": self.parent.method,
253
+ "vcov": self.vcov_type,
254
+ }
255
+ )
256
+ return pd.DataFrame(rows)
257
+
258
+ def confint(
259
+ self,
260
+ level: float = 0.95,
261
+ *,
262
+ interval: str = "percentile",
263
+ band: str = "pointwise",
264
+ shock: str | Iterable[str] | None = None,
265
+ outcome: str | Iterable[str] | None = None,
266
+ ) -> pd.DataFrame:
267
+ """Return bootstrap confidence intervals indexed by horizon when possible."""
268
+
269
+ selected_outcomes = self.parent._select(
270
+ outcome, available=self.outcome_names, name="outcome"
271
+ )
272
+ selected_shocks = self.parent._select(shock, available=self.shock_names, name="shock")
273
+ table = self.tidy(level=level, interval=interval, band=band, outcome=outcome, shock=shock)
274
+ if len(selected_outcomes) == 1 and len(selected_shocks) == 1:
275
+ return table.set_index("horizon")[["conf_low", "conf_high"]]
276
+ return table[["outcome", "shock", "horizon", "conf_low", "conf_high"]].reset_index(
277
+ drop=True
278
+ )
279
+
280
+ def plot(
281
+ self,
282
+ level: float = 0.95,
283
+ *,
284
+ interval: str = "percentile",
285
+ band: str = "pointwise",
286
+ **kwargs: Any,
287
+ ) -> Any:
288
+ """Return a Plotnine IRF plot with bootstrap intervals."""
289
+
290
+ from .plotting import plot_irf
291
+
292
+ return plot_irf(self, level=level, band=band, interval=interval, **kwargs)
293
+
294
+
295
+ def _normalize_method(method: str) -> str:
296
+ value = method.lower().replace("_", "-")
297
+ if value not in _BOOTSTRAP_METHOD_ALIASES:
298
+ raise ValueError("bootstrap method must be 'wild'")
299
+ return _BOOTSTRAP_METHOD_ALIASES[value]
300
+
301
+
302
+ def _normalize_weights(weights: str) -> str:
303
+ value = weights.lower().replace("_", "-")
304
+ if value not in _WEIGHT_ALIASES:
305
+ raise ValueError("wild bootstrap weights must be 'rademacher', 'mammen', or 'normal'")
306
+ return _WEIGHT_ALIASES[value]
307
+
308
+
309
+ def _is_cluster_vcov(vcov: Any) -> bool:
310
+ return isinstance(vcov, dict) and "type" not in vcov and "kind" not in vcov
311
+
312
+
313
+ def _validate_bootstrap_scope(result: LPResult) -> None:
314
+ metadata = result.metadata
315
+ if metadata.get("backend") != "pyfixest" or metadata.get("estimator") != "feols":
316
+ raise NotImplementedError("bootstrap inference currently supports regular PyFixest OLS LPs")
317
+ if metadata.get("iv") is not None or str(result.method).endswith("iv"):
318
+ raise NotImplementedError("bootstrap inference is not implemented for IV/proxy LPs")
319
+ if metadata.get("fixed_effects") not in {None, "0", "1"}:
320
+ raise NotImplementedError("bootstrap inference is not implemented for fixed effects")
321
+ if metadata.get("groupby") is not None:
322
+ raise NotImplementedError("bootstrap inference is not implemented for panel/grouped LPs")
323
+ requested_vcov = metadata.get("requested_vcov")
324
+ if _is_cluster_vcov(requested_vcov):
325
+ raise NotImplementedError(
326
+ "bootstrap inference is not implemented for clustered covariance specs"
327
+ )
328
+ if requested_vcov is not None:
329
+ spec = parse_vcov(requested_vcov)
330
+ if spec.kind.lower() in {"hac", "newey-west", "nw"}:
331
+ raise NotImplementedError(
332
+ "wild bootstrap inference is not implemented for HAC/Newey-West LPs"
333
+ )
334
+
335
+
336
+ def _draw_wild_weights(
337
+ rng: np.random.Generator, *, reps: int, nobs: int, weights: str
338
+ ) -> np.ndarray:
339
+ if weights == "rademacher":
340
+ return rng.choice(np.array([-1.0, 1.0]), size=(reps, nobs))
341
+ if weights == "normal":
342
+ return rng.standard_normal(size=(reps, nobs))
343
+ if weights == "mammen":
344
+ sqrt5 = np.sqrt(5.0)
345
+ low = (1.0 - sqrt5) / 2.0
346
+ high = (1.0 + sqrt5) / 2.0
347
+ probability_low = (sqrt5 + 1.0) / (2.0 * sqrt5)
348
+ return np.where(rng.random(size=(reps, nobs)) < probability_low, low, high)
349
+ raise ValueError(f"unknown wild bootstrap weights: {weights!r}")
350
+
351
+
352
+ def _linear_design(fit: HorizonFit) -> tuple[np.ndarray, np.ndarray, np.ndarray, list[str]]:
353
+ linear = fit.linear
354
+ if linear is None:
355
+ raise NotImplementedError("bootstrap inference requires normalized linear-fit state")
356
+ if linear.is_iv:
357
+ raise NotImplementedError("bootstrap inference is not implemented for IV/proxy LPs")
358
+ if linear.has_fixed_effects:
359
+ raise NotImplementedError("bootstrap inference is not implemented for fixed effects")
360
+ return (
361
+ linear.design,
362
+ linear.residuals,
363
+ linear.coefficients,
364
+ list(linear.coefficient_names),
365
+ )
366
+
367
+
368
+ def _bootstrap_fit_coefficients(
369
+ fit: HorizonFit,
370
+ *,
371
+ shock_names: list[str],
372
+ weights_matrix: np.ndarray,
373
+ ) -> pd.DataFrame:
374
+ x, residual, beta, names = _linear_design(fit)
375
+ missing = [shock for shock in shock_names if shock not in names]
376
+ if missing:
377
+ raise ValueError(
378
+ f"shock coefficient(s) {missing!r} are not in the fitted model for horizon {fit.horizon}"
379
+ )
380
+ if weights_matrix.shape[1] != x.shape[0]:
381
+ raise ValueError("wild bootstrap weight dimension does not match horizon sample size")
382
+
383
+ bread = np.linalg.pinv(x.T @ x)
384
+ scores = (weights_matrix * residual[None, :]) @ x
385
+ beta_star = beta[None, :] + scores @ bread.T
386
+ reps = weights_matrix.shape[0]
387
+
388
+ rows: list[pd.DataFrame] = []
389
+ for shock in shock_names:
390
+ position = names.index(shock)
391
+ rows.append(
392
+ pd.DataFrame(
393
+ {
394
+ "rep": np.arange(reps, dtype=int),
395
+ "horizon": fit.horizon,
396
+ "shock": shock,
397
+ "estimate": beta_star[:, position],
398
+ }
399
+ )
400
+ )
401
+ return pd.concat(rows, ignore_index=True)
402
+
403
+
404
+ def bootstrap(
405
+ result: LPResult,
406
+ *,
407
+ reps: int = 999,
408
+ method: str = "wild",
409
+ weights: str = "rademacher",
410
+ seed: int | None = None,
411
+ ) -> BootstrapResult:
412
+ """Generate bootstrap coefficient draws for a fitted regular LP."""
413
+
414
+ if reps < 2:
415
+ raise ValueError("reps must be at least 2")
416
+ method_type = _normalize_method(method)
417
+ weight_type = _normalize_weights(weights)
418
+ _validate_bootstrap_scope(result)
419
+
420
+ rng = np.random.default_rng(seed)
421
+ common_sample = result.metadata.get("sample") == "common"
422
+ common_weights: np.ndarray | None = None
423
+ rows: list[pd.DataFrame] = []
424
+ for outcome_name in result.outcome_names:
425
+ for horizon in result.horizons:
426
+ fit = result._fit_for(outcome_name, horizon)
427
+ if common_sample:
428
+ if common_weights is None:
429
+ common_weights = _draw_wild_weights(
430
+ rng, reps=reps, nobs=fit.nobs, weights=weight_type
431
+ )
432
+ elif common_weights.shape[1] != fit.nobs:
433
+ raise ValueError(
434
+ "sample='common' bootstrap requires identical horizon sample sizes"
435
+ )
436
+ weights_matrix = common_weights
437
+ else:
438
+ weights_matrix = _draw_wild_weights(
439
+ rng, reps=reps, nobs=fit.nobs, weights=weight_type
440
+ )
441
+ fit_draws = _bootstrap_fit_coefficients(
442
+ fit,
443
+ shock_names=result.shock_names,
444
+ weights_matrix=weights_matrix,
445
+ )
446
+ fit_draws.insert(1, "outcome", outcome_name)
447
+ rows.append(fit_draws)
448
+
449
+ estimates = pd.concat(rows, ignore_index=True)
450
+ estimates = estimates[["rep", "outcome", "shock", "horizon", "estimate"]]
451
+ return BootstrapResult(
452
+ parent=result,
453
+ estimates=estimates,
454
+ reps=reps,
455
+ method=method_type,
456
+ weights=weight_type,
457
+ seed=seed,
458
+ )