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 +19 -0
- localproj/_fitted.py +203 -0
- localproj/bootstrap.py +458 -0
- localproj/core.py +403 -0
- localproj/estimators/__init__.py +5 -0
- localproj/estimators/base.py +33 -0
- localproj/estimators/bayesian.py +1309 -0
- localproj/estimators/bias_corrected.py +326 -0
- localproj/estimators/high_dimensional.py +1030 -0
- localproj/estimators/panel.py +233 -0
- localproj/estimators/regular.py +559 -0
- localproj/estimators/smooth.py +787 -0
- localproj/formula.py +212 -0
- localproj/horizon.py +395 -0
- localproj/plotting.py +193 -0
- localproj/result.py +506 -0
- localproj/transforms.py +213 -0
- localproj/vcov.py +97 -0
- localproj-0.1.0.dist-info/METADATA +313 -0
- localproj-0.1.0.dist-info/RECORD +22 -0
- localproj-0.1.0.dist-info/WHEEL +4 -0
- localproj-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|