bdmjtest 1.0.1__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.
- bdmjtest/__init__.py +10 -0
- bdmjtest/_version.py +24 -0
- bdmjtest/bdmjtest.py +819 -0
- bdmjtest-1.0.1.dist-info/METADATA +98 -0
- bdmjtest-1.0.1.dist-info/RECORD +6 -0
- bdmjtest-1.0.1.dist-info/WHEEL +4 -0
bdmjtest/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Bootstrapped Davidson-MacKinnon J-test.
|
|
2
|
+
|
|
3
|
+
Provides a fast Bootstrapped J-test implementation for comparing non-nested linear and non-linear models
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .bdmjtest import jtest_np, jtest_sm, jtest_scipy, JtestResults
|
|
7
|
+
__all__ = ["jtest_np",
|
|
8
|
+
"jtest_sm",
|
|
9
|
+
"jtest_scipy",
|
|
10
|
+
"JtestResults"]
|
bdmjtest/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
bdmjtest/bdmjtest.py
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Union, Optional, Literal, Callable, Any, Dict, Tuple
|
|
3
|
+
import numpy as np
|
|
4
|
+
import scipy.stats as stats
|
|
5
|
+
import statsmodels.api as sm
|
|
6
|
+
from scipy.optimize import curve_fit
|
|
7
|
+
try:
|
|
8
|
+
from lmfit import Model
|
|
9
|
+
HAS_LMFIT = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
HAS_LMFIT = False
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class JtestResults:
|
|
15
|
+
"""Dataclass holding the outputs of the Davidson-MacKinnon J-test.
|
|
16
|
+
|
|
17
|
+
Attributes
|
|
18
|
+
----------
|
|
19
|
+
t_asy : float
|
|
20
|
+
Aymptotic J-test t-statistic computed from original sample data.
|
|
21
|
+
p_asy : float
|
|
22
|
+
Asymptotic p-value based on Student's t-distribution.
|
|
23
|
+
p_boot : float
|
|
24
|
+
Bootstrap p-value calculated across successful iterations.
|
|
25
|
+
s_its : float
|
|
26
|
+
Total number of successfully executed bootstraps.
|
|
27
|
+
residuals : np.ndarray, optional
|
|
28
|
+
Residuals from the null model (H0), returned if requested.
|
|
29
|
+
fitted : np.ndarray, optional
|
|
30
|
+
Fitted values from the null model (H0), returned if requested.
|
|
31
|
+
"""
|
|
32
|
+
# Primary variables
|
|
33
|
+
t_asy : float
|
|
34
|
+
p_asy : float
|
|
35
|
+
p_boot : float
|
|
36
|
+
s_its : int
|
|
37
|
+
# Optional variables
|
|
38
|
+
t_boot: Optional[np.ndarray] = None
|
|
39
|
+
residuals: Optional[np.ndarray] = None
|
|
40
|
+
fitted : Optional[np.ndarray] = None
|
|
41
|
+
|
|
42
|
+
# NumPy backend
|
|
43
|
+
def jtest_np(
|
|
44
|
+
y: np.ndarray,
|
|
45
|
+
X: np.ndarray,
|
|
46
|
+
Z: np.ndarray,
|
|
47
|
+
B: int = 999,
|
|
48
|
+
boot_method: Literal["residuals","semiparametric", "wild", "parametric", "pairs"] = "residuals",
|
|
49
|
+
seed: Optional[Union[int, np.random.Generator]] = None,
|
|
50
|
+
**kwargs
|
|
51
|
+
) -> JtestResults:
|
|
52
|
+
"""Performs the Bootstrap Davidson-MacKinnon J-test with NumPy backend.
|
|
53
|
+
|
|
54
|
+
Test a linear null model (H1) (y on X) against a linear non-nested alternative (H2) (y on Z).
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
y : array-like of shape (N,)
|
|
59
|
+
Dependent variable vector.
|
|
60
|
+
X : array-like of shape (N, k1) or (N,)
|
|
61
|
+
Explanatory variables under the null hypothesis (H1).
|
|
62
|
+
Z : array-like of shape (N, k2) or (N,)
|
|
63
|
+
Explanatory variables under the alternative hypothesis (H2).
|
|
64
|
+
B : int, default=999
|
|
65
|
+
Number of bootstrap replications.
|
|
66
|
+
boot_method : {'residuals', 'semiparametric', 'wild', 'parametric', 'pairs'}, default='residuals'
|
|
67
|
+
Bootstrap Data Generation Process (DGP) method.
|
|
68
|
+
**kwargs : dict
|
|
69
|
+
Optional controls:
|
|
70
|
+
- return_residuals (bool): Return H1 residuals (default: False).
|
|
71
|
+
- return_fitted (bool): Return H1 fitted values (default: False).
|
|
72
|
+
- res_distribution (str): Auxiliary distribution for wild bootstrap ('rademacher' or 'mammen').
|
|
73
|
+
- scaled_residuals (str): Residual transformation ('simple' or 'leverage').
|
|
74
|
+
- boot_tails (str): Tail definition for bootstrap p-value ('one' or 'two').
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
----------
|
|
78
|
+
JtestResults
|
|
79
|
+
Dataclass containing test statistics, p-values, and optional series.
|
|
80
|
+
"""
|
|
81
|
+
return_residuals: bool = kwargs.get('return_residuals', False)
|
|
82
|
+
return_fitted: bool = kwargs.get('return_fitted', False)
|
|
83
|
+
res_distribution: str = kwargs.get('res_distribution', 'rademacher')
|
|
84
|
+
scaled_residuals: str = kwargs.get('scaled_residuals', 'simple')
|
|
85
|
+
boot_tails: str = kwargs.get('boot_tails', 'one')
|
|
86
|
+
return_t_boot: bool = kwargs.get('return_t_boot', False)
|
|
87
|
+
|
|
88
|
+
rng = np.random.default_rng(seed)
|
|
89
|
+
# Data preprocessing
|
|
90
|
+
y_f = np.asarray(y, dtype=float).ravel()
|
|
91
|
+
N = len(y_f)
|
|
92
|
+
# Ensure 2D matrices
|
|
93
|
+
X_mat = np.atleast_2d(X).T if np.ndim(X) == 1 else np.asarray(X)
|
|
94
|
+
Z_mat = np.atleast_2d(Z).T if np.ndim(Z) == 1 else np.asarray(Z)
|
|
95
|
+
|
|
96
|
+
ones_col = np.ones((N,1)) #Column vector for intercept terms
|
|
97
|
+
# 1. Fit base models
|
|
98
|
+
# Model H1
|
|
99
|
+
A1 = np.column_stack([X_mat, ones_col])
|
|
100
|
+
coeffs1, _, _, _ = np.linalg.lstsq(A1, y_f, rcond=None)
|
|
101
|
+
y1_h = A1 @ coeffs1
|
|
102
|
+
k1 = A1.shape[1]
|
|
103
|
+
# Model H2
|
|
104
|
+
A2 = np.column_stack([Z_mat, ones_col])
|
|
105
|
+
coeffs2, _, _, _ = np.linalg.lstsq(A2, y_f, rcond=None)
|
|
106
|
+
y2_h = A2 @ coeffs2
|
|
107
|
+
# 2. Augmented regression
|
|
108
|
+
# Augmented model
|
|
109
|
+
A_aug = np.column_stack([A1, y2_h])
|
|
110
|
+
coeffs_aug, _, _, _ = np.linalg.lstsq(A_aug, y_f, rcond=None)
|
|
111
|
+
res_aug = y_f - (A_aug @ coeffs_aug)
|
|
112
|
+
ssr_aug = np.sum(res_aug ** 2)
|
|
113
|
+
# J-statistic and asymptotic p-value
|
|
114
|
+
n_aug, p_aug = A_aug.shape
|
|
115
|
+
dof_aug = n_aug - p_aug
|
|
116
|
+
dof_aug = N - A_aug.shape[1]
|
|
117
|
+
mse_aug = ssr_aug / dof_aug
|
|
118
|
+
cov_aug = mse_aug * np.linalg.pinv(A_aug.T @ A_aug)
|
|
119
|
+
se_obs = np.sqrt(cov_aug[-1, -1])
|
|
120
|
+
t_stat_obs = float(coeffs_aug[-1] / se_obs)
|
|
121
|
+
p_value_obs = float(stats.t.sf(np.abs(t_stat_obs), dof_aug) * 2)
|
|
122
|
+
|
|
123
|
+
# 3. Residual scaling and bootstrap setup
|
|
124
|
+
res_y1 = y_f - y1_h
|
|
125
|
+
dof_h1 = N - k1
|
|
126
|
+
|
|
127
|
+
if boot_method in ['residuals', 'semiparametric']:
|
|
128
|
+
res_centered = res_y1 - np.mean(res_y1) # Center residuals under H1 assumption
|
|
129
|
+
if scaled_residuals == 'simple':
|
|
130
|
+
res_scaled = res_centered * np.sqrt(N / dof_h1)
|
|
131
|
+
elif scaled_residuals == 'leverage':
|
|
132
|
+
Q, _ = np.linalg.qr(A1)
|
|
133
|
+
leverage = np.sum(Q**2, axis=1)
|
|
134
|
+
res_scaled = res_centered / np.sqrt(1.0 - leverage)
|
|
135
|
+
else:
|
|
136
|
+
raise ValueError('Supported residual scaling are "simple" and "leverage".')
|
|
137
|
+
|
|
138
|
+
elif boot_method == 'wild':
|
|
139
|
+
if scaled_residuals == 'simple':
|
|
140
|
+
res_scaled = res_y1 * np.sqrt(N / dof_h1)
|
|
141
|
+
elif scaled_residuals == 'leverage':
|
|
142
|
+
Q, _ = np.linalg.qr(A1)
|
|
143
|
+
leverage = np.sum(Q**2, axis=1)
|
|
144
|
+
res_scaled = res_y1 / np.sqrt(1.0 - leverage)
|
|
145
|
+
else:
|
|
146
|
+
raise ValueError('Supported residual scaling are "simple" and "leverage".')
|
|
147
|
+
|
|
148
|
+
elif boot_method == 'parametric':
|
|
149
|
+
res_std = np.sqrt(np.sum(res_y1**2) / dof_h1)
|
|
150
|
+
|
|
151
|
+
elif boot_method == 'pairs':
|
|
152
|
+
pass # Pairs bootstrap resamples raw data directly
|
|
153
|
+
|
|
154
|
+
else:
|
|
155
|
+
raise ValueError('Supported boot_method: "pairs", "wild", "residuals", "parametric".')
|
|
156
|
+
|
|
157
|
+
# 4. Bootstrap Loop
|
|
158
|
+
t_stat_boot = []
|
|
159
|
+
|
|
160
|
+
for b in range(B):
|
|
161
|
+
try:
|
|
162
|
+
if boot_method == 'pairs':
|
|
163
|
+
# Resample observations (y, X, Z) simultaneously
|
|
164
|
+
b_idx = rng.choice(N, size=N, replace=True)
|
|
165
|
+
y_b, X_mat_b, Z_mat_b = y_f[b_idx], X_mat[b_idx], Z_mat[b_idx]
|
|
166
|
+
# Fit H2 in bootstrap sample
|
|
167
|
+
A2_b = np.column_stack([Z_mat_b, ones_col])
|
|
168
|
+
coeffs2_b, _, _, _ = np.linalg.lstsq(A2_b, y_b, rcond=None)
|
|
169
|
+
y2_h_b = A2_b @ coeffs2_b
|
|
170
|
+
# Fit augmented model on bootstrap sample
|
|
171
|
+
A_aug_b = np.concatenate([X_mat_b, ones_col, y2_h_b.reshape(-1, 1)], axis=1)
|
|
172
|
+
coeffs_b, res_b, _, _ = np.linalg.lstsq(A_aug_b, y_b, rcond=None)
|
|
173
|
+
# Bootstrap J-statistic
|
|
174
|
+
resid_b = y_b - (A_aug_b @ coeffs_b)
|
|
175
|
+
mse_b = np.sum(resid_b**2) / (A_aug_b.shape[0] - A_aug_b.shape[1])
|
|
176
|
+
cov_b = mse_b * np.linalg.pinv(A_aug_b.T @ A_aug_b)
|
|
177
|
+
se_b = np.sqrt(cov_b[-1, -1])
|
|
178
|
+
t_stat_boot.append((coeffs_b[-1]-coeffs_aug[-1]) / se_b) # Centered for pairs bootstrap
|
|
179
|
+
|
|
180
|
+
else:
|
|
181
|
+
# Generate bootstrap samples y_star under H1
|
|
182
|
+
if boot_method == 'wild':
|
|
183
|
+
if res_distribution == 'rademacher':
|
|
184
|
+
v = rng.choice([-1.0, 1.0], size=N, replace=True)
|
|
185
|
+
elif res_distribution == 'mammen':
|
|
186
|
+
v_vals = [-(np.sqrt(5)-1) / 2, (np.sqrt(5)+1) / 2]
|
|
187
|
+
v_probs = [(np.sqrt(5) + 1) / (2 * np.sqrt(5)), (np.sqrt(5) - 1) / (2 * np.sqrt(5))]
|
|
188
|
+
v = rng.choice(v_vals, size=N, replace=True, p=v_probs)
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError('Supported distributions are "rademacher" and "mammen"')
|
|
191
|
+
y_star = y1_h + res_scaled * v
|
|
192
|
+
|
|
193
|
+
elif boot_method in ['residuals', 'semiparametric']:
|
|
194
|
+
b_idx = rng.choice(N, size=N, replace=True)
|
|
195
|
+
y_star = y1_h + res_scaled[b_idx]
|
|
196
|
+
|
|
197
|
+
elif boot_method == 'parametric':
|
|
198
|
+
y_star = y1_h + rng.normal(loc=0.0, scale=res_std, size=N)
|
|
199
|
+
# Fit H2 on y_star
|
|
200
|
+
coeffs2_b, _, _, _ = np.linalg.lstsq(A2, y_star, rcond=None)
|
|
201
|
+
y2_h_b = A2 @ coeffs2_b
|
|
202
|
+
# Fit augmented model on y_star
|
|
203
|
+
A_aug_b = np.column_stack([A1, y2_h_b])
|
|
204
|
+
coeffs_b, _, _, _ = np.linalg.lstsq(A_aug_b, y_star, rcond=None)
|
|
205
|
+
# Bootstrap J-statistic
|
|
206
|
+
resid_b = y_star - (A_aug_b @ coeffs_b)
|
|
207
|
+
mse_b = np.sum(resid_b**2) / (A_aug_b.shape[0] - A_aug_b.shape[1])
|
|
208
|
+
cov_b = mse_b * np.linalg.pinv(A_aug_b.T @ A_aug_b)
|
|
209
|
+
se_b = np.sqrt(cov_b[-1, -1])
|
|
210
|
+
t_stat_boot.append(coeffs_b[-1] / se_b)
|
|
211
|
+
|
|
212
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
213
|
+
continue # Skip collinear or singular iterations
|
|
214
|
+
|
|
215
|
+
# 5. Bootstrap P-value
|
|
216
|
+
t_boot_arr = np.array(t_stat_boot)
|
|
217
|
+
s_iterations = len(t_boot_arr)
|
|
218
|
+
|
|
219
|
+
if boot_tails == 'one':
|
|
220
|
+
p_value_boot = float((1 + np.sum(t_boot_arr >= t_stat_obs)) / (1 + s_iterations))
|
|
221
|
+
elif boot_tails == 'two':
|
|
222
|
+
p_value_boot = float((1 + np.sum(np.abs(t_boot_arr) >= np.abs(t_stat_obs)))/(1 + s_iterations))
|
|
223
|
+
else:
|
|
224
|
+
raise ValueError('boot_tails option must be "one" or "two"')
|
|
225
|
+
|
|
226
|
+
# 6. Outputs
|
|
227
|
+
res_arr = res_y1 if return_residuals else None
|
|
228
|
+
fitted_arr = y1_h if return_fitted else None
|
|
229
|
+
t_boot_dist = t_boot_arr if return_t_boot else None
|
|
230
|
+
return JtestResults(
|
|
231
|
+
t_asy = t_stat_obs,
|
|
232
|
+
p_asy = p_value_obs,
|
|
233
|
+
p_boot = p_value_boot,
|
|
234
|
+
s_its = int(s_iterations),
|
|
235
|
+
residuals = res_arr,
|
|
236
|
+
fitted = fitted_arr,
|
|
237
|
+
t_boot=t_boot_dist
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# statsmodels.OLS backend
|
|
241
|
+
def jtest_sm(
|
|
242
|
+
y: np.ndarray,
|
|
243
|
+
X: np.ndarray,
|
|
244
|
+
Z: np.ndarray,
|
|
245
|
+
B: int = 999,
|
|
246
|
+
boot_method: Literal["residuals", "semiparametric", "wild", "parametric", "pairs"] = "residuals",
|
|
247
|
+
seed: Optional[Union[int, np.random.Generator]] = None,
|
|
248
|
+
**kwargs
|
|
249
|
+
) -> JtestResults:
|
|
250
|
+
"""Execute the Bootstrapped Davidson-MacKinnon J-test using statsmodels.OLS.
|
|
251
|
+
|
|
252
|
+
Evaluates primary Model H1 (y on X) against non-nested alternative Model H2 (y on Z).
|
|
253
|
+
|
|
254
|
+
Parameters
|
|
255
|
+
----------
|
|
256
|
+
y : array-like of shape (N,)
|
|
257
|
+
Dependent variable vector.
|
|
258
|
+
X : array-like of shape (N, k1) or (N,)
|
|
259
|
+
Explanatory variables under Model H1.
|
|
260
|
+
Z : array-like of shape (N, k2) or (N,)
|
|
261
|
+
Explanatory variables under Model H2.
|
|
262
|
+
B : int, default=999
|
|
263
|
+
Number of bootstrap replications.
|
|
264
|
+
boot_method : {'residuals', 'semiparametric', 'wild', 'parametric', 'pairs'}, default='residuals'
|
|
265
|
+
Bootstrap Data Generation Process (DGP) method.
|
|
266
|
+
**kwargs : dict
|
|
267
|
+
Optional controls:
|
|
268
|
+
- returnmodelresiduals (bool): Return H1 residuals (default: False).
|
|
269
|
+
- returnfittedvalues (bool): Return H1 fitted values (default: False).
|
|
270
|
+
- res_distribution (str): Auxiliary distribution for wild bootstrap ('rademacher' or 'mammen').
|
|
271
|
+
- scaled_residuals (str): Residual scaling method ('simple' or 'leverage').
|
|
272
|
+
- boot_tails (str): Tail definition for bootstrap p-value ('one' or 'two').
|
|
273
|
+
|
|
274
|
+
Returns
|
|
275
|
+
-------
|
|
276
|
+
JtestResults
|
|
277
|
+
Dataclass object containing test statistics, p-values, and optional series.
|
|
278
|
+
"""
|
|
279
|
+
return_residuals: bool = kwargs.get('return_residuals', False)
|
|
280
|
+
return_fitted: bool = kwargs.get('return_fitted', False)
|
|
281
|
+
res_distribution: str = kwargs.get('res_distribution', 'rademacher')
|
|
282
|
+
scaled_residuals: str = kwargs.get('scaled_residuals', 'simple')
|
|
283
|
+
boot_tails: str = kwargs.get('boot_tails', 'one')
|
|
284
|
+
|
|
285
|
+
rng = np.random.default_rng(seed)
|
|
286
|
+
# Data preprocessing
|
|
287
|
+
y_f = np.asarray(y, dtype=float).ravel()
|
|
288
|
+
N = len(y_f)
|
|
289
|
+
# Ensure 2D matrices
|
|
290
|
+
X_mat = np.atleast_2d(X).T if np.ndim(X) == 1 else np.asarray(X, dtype=float)
|
|
291
|
+
Z_mat = np.atleast_2d(Z).T if np.ndim(Z) == 1 else np.asarray(Z, dtype=float)
|
|
292
|
+
|
|
293
|
+
indices = np.arange(N)
|
|
294
|
+
ones_col = np.ones((N, 1))
|
|
295
|
+
# 1. Fit base models
|
|
296
|
+
# Model H1
|
|
297
|
+
A1 = np.concatenate([X_mat, ones_col], axis=1)
|
|
298
|
+
m1 = sm.OLS(y_f, A1).fit()
|
|
299
|
+
y1_h = m1.fittedvalues
|
|
300
|
+
dof_h1 = m1.df_resid
|
|
301
|
+
# Model H2
|
|
302
|
+
A2 = np.concatenate([Z_mat, ones_col], axis=1)
|
|
303
|
+
m2 = sm.OLS(y_f, A2).fit()
|
|
304
|
+
y2_h = m2.fittedvalues
|
|
305
|
+
# Augmented regression
|
|
306
|
+
A_aug = np.concatenate([A1, np.asarray(y2_h).reshape(-1, 1)], axis=1)
|
|
307
|
+
m_aug = sm.OLS(y_f, A_aug).fit()
|
|
308
|
+
# J-statistic and asymptotic p-value
|
|
309
|
+
t_stat_obs = float(m_aug.tvalues[-1])
|
|
310
|
+
p_value_obs = float(m_aug.pvalues[-1])
|
|
311
|
+
|
|
312
|
+
# 3. Residual scaling and bootstrap setup
|
|
313
|
+
res_y1 = m1.resid
|
|
314
|
+
|
|
315
|
+
if boot_method in ['residuals', 'semiparametric']:
|
|
316
|
+
res_centered = res_y1 - np.mean(res_y1)
|
|
317
|
+
if scaled_residuals == 'simple':
|
|
318
|
+
res_scaled = res_centered * np.sqrt(N / dof_h1)
|
|
319
|
+
elif scaled_residuals == 'leverage':
|
|
320
|
+
leverage = m1.get_influence().hat_matrix_diag # Hat matrix diagonal
|
|
321
|
+
res_scaled = res_centered / np.sqrt(1.0 - leverage)
|
|
322
|
+
else:
|
|
323
|
+
raise ValueError('Supported residual scaling options: "simple" or "leverage".')
|
|
324
|
+
|
|
325
|
+
elif boot_method == 'wild':
|
|
326
|
+
if scaled_residuals == 'simple':
|
|
327
|
+
res_scaled = res_y1 * np.sqrt(N / dof_h1)
|
|
328
|
+
elif scaled_residuals == 'leverage':
|
|
329
|
+
leverage = m1.get_influence().hat_matrix_diag
|
|
330
|
+
res_scaled = res_y1 / np.sqrt(1.0 - leverage)
|
|
331
|
+
else:
|
|
332
|
+
raise ValueError('Supported residual scaling options: "simple" or "leverage".')
|
|
333
|
+
|
|
334
|
+
elif boot_method == 'parametric':
|
|
335
|
+
res_std = np.sqrt(m1.ssr / dof_h1)
|
|
336
|
+
|
|
337
|
+
elif boot_method == 'pairs':
|
|
338
|
+
pass # Pairs bootstrap resamples raw data directly
|
|
339
|
+
|
|
340
|
+
else:
|
|
341
|
+
raise ValueError('Invalid boot_method. Supported options: "pairs", "wild", "residuals", "parametric".')
|
|
342
|
+
|
|
343
|
+
# 4. Bootstrap loop
|
|
344
|
+
t_stat_boot = []
|
|
345
|
+
|
|
346
|
+
for b in range(B):
|
|
347
|
+
try:
|
|
348
|
+
if boot_method == 'pairs':
|
|
349
|
+
b_idx = rng.choice(N, size=N, replace=True)
|
|
350
|
+
y_b, X_mat_b, Z_mat_b = y_f[b_idx], X_mat[b_idx], Z_mat[b_idx]
|
|
351
|
+
|
|
352
|
+
A2_b = np.concatenate([Z_mat_b, ones_col], axis=1)
|
|
353
|
+
m2_b = sm.OLS(y_b, A2_b).fit()
|
|
354
|
+
y2_h_b = m2_b.fittedvalues
|
|
355
|
+
|
|
356
|
+
A_aug_b = np.concatenate([X_mat_b, ones_col, np.asarray(y2_h_b).reshape(-1, 1)], axis=1)
|
|
357
|
+
m_aug_b = sm.OLS(y_b, A_aug_b).fit()
|
|
358
|
+
|
|
359
|
+
t_stat_boot.append(m_aug_b.tvalues[-1] - t_stat_obs)
|
|
360
|
+
|
|
361
|
+
else:
|
|
362
|
+
if boot_method == 'wild':
|
|
363
|
+
if res_distribution == 'rademacher':
|
|
364
|
+
v = rng.choice([-1.0, 1.0], size=N, replace=True)
|
|
365
|
+
elif res_distribution == 'mammen':
|
|
366
|
+
v_vals = [-(np.sqrt(5) - 1) / 2, (np.sqrt(5) + 1) / 2]
|
|
367
|
+
v_probs = [(np.sqrt(5) + 1) / (2 * np.sqrt(5)), (np.sqrt(5) - 1) / (2 * np.sqrt(5))]
|
|
368
|
+
v = rng.choice(v_vals, size=N, replace=True, p=v_probs)
|
|
369
|
+
else:
|
|
370
|
+
raise ValueError('Supported wild distributions: "rademacher" or "mammen".')
|
|
371
|
+
y_star = y1_h + res_scaled * v
|
|
372
|
+
|
|
373
|
+
elif boot_method in ['residuals', 'semiparametric']:
|
|
374
|
+
b_idx = rng.choice(N, size=N, replace=True)
|
|
375
|
+
y_star = y1_h + res_scaled[b_idx]
|
|
376
|
+
|
|
377
|
+
elif boot_method == 'parametric':
|
|
378
|
+
y_star = y1_h + rng.normal(loc=0.0, scale=res_std, size=N)
|
|
379
|
+
|
|
380
|
+
m2_b = sm.OLS(y_star, A2).fit()
|
|
381
|
+
y2_h_b = m2_b.fittedvalues
|
|
382
|
+
|
|
383
|
+
A_aug_b = np.concatenate([A1, np.asarray(y2_h_b).reshape(-1, 1)], axis=1)
|
|
384
|
+
m_aug_b = sm.OLS(y_star, A_aug_b).fit()
|
|
385
|
+
|
|
386
|
+
t_stat_boot.append(m_aug_b.tvalues[-1])
|
|
387
|
+
|
|
388
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
# 5. Bootstrap p-value
|
|
392
|
+
t_boot_arr = np.array(t_stat_boot)
|
|
393
|
+
s_iterations = len(t_boot_arr)
|
|
394
|
+
|
|
395
|
+
if boot_tails == 'one':
|
|
396
|
+
p_value_boot = float((1 + np.sum(t_boot_arr >= t_stat_obs)) / (1 + s_iterations))
|
|
397
|
+
elif boot_tails == 'two':
|
|
398
|
+
p_value_boot = float((1 + np.sum(np.abs(t_boot_arr) >= np.abs(t_stat_obs))) / (1 + s_iterations))
|
|
399
|
+
else:
|
|
400
|
+
raise ValueError('boot_tails option must be "one" or "two".')
|
|
401
|
+
|
|
402
|
+
# 6. Outputs
|
|
403
|
+
res_arr = np.asarray(res_y1) if return_residuals else None
|
|
404
|
+
fitted_arr = np.asarray(y1_h) if return_fitted else None
|
|
405
|
+
|
|
406
|
+
return JtestResults(
|
|
407
|
+
t_asy=t_stat_obs,
|
|
408
|
+
p_asy=p_value_obs,
|
|
409
|
+
p_boot=p_value_boot,
|
|
410
|
+
s_its=int(s_iterations),
|
|
411
|
+
residuals=res_arr,
|
|
412
|
+
fitted=fitted_arr
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# SciPy backend (+ statsmodels.OLS and LMFIT)
|
|
416
|
+
def jtest_scipy(
|
|
417
|
+
y: np.ndarray,
|
|
418
|
+
X: np.ndarray,
|
|
419
|
+
Z: np.ndarray,
|
|
420
|
+
func_X: Callable,
|
|
421
|
+
func_Z: Callable,
|
|
422
|
+
B: int = 999,
|
|
423
|
+
boot_method: Literal["residuals", "semiparametric", "wild", "parametric", "pairs"] = "pairs",
|
|
424
|
+
seed: Optional[Union[int, np.random.Generator]] = None,
|
|
425
|
+
**kwargs: Any
|
|
426
|
+
) -> JtestResults:
|
|
427
|
+
"""Perform the Bootstrapped Davidson-MacKinnon J-test for linear and non-linear regression models. Uses a SciPy backend.
|
|
428
|
+
|
|
429
|
+
Evaluates primary non-linear Model H1 (funx_X) against non-nested alternative
|
|
430
|
+
Model H2 (func_Z) across multiple backend solvers ('Hybrid statsmodels.OLS - SciPy.curve_fit', 'scipy.curve_fit', 'LMFIT').
|
|
431
|
+
|
|
432
|
+
Parameters
|
|
433
|
+
----------
|
|
434
|
+
y : array-like of shape (N,)
|
|
435
|
+
Dependent variable vector.
|
|
436
|
+
X : array-like of shape (N, k1) or (N,)
|
|
437
|
+
Explanatory variables under Model H1.
|
|
438
|
+
Z : array-like of shape (N, k2) or (N,)
|
|
439
|
+
Explanatory variables under Model H2.
|
|
440
|
+
func_X : callable
|
|
441
|
+
Model function for H1:
|
|
442
|
+
- For 'hybrid-ols' & 'scipy': `f(x, *params)` expecting positional arguments.
|
|
443
|
+
- For 'lmfit': `f(x, param1, param2, ...)` expecting named keyword arguments.
|
|
444
|
+
func_Z : callable
|
|
445
|
+
Model function for H2 (same signature requirements as `func_X`).
|
|
446
|
+
B : int, default=999
|
|
447
|
+
Number of bootstrap replications.
|
|
448
|
+
boot_method : {'pairs', 'residuals', 'semiparametric', 'wild', 'parametric'}, default='pairs'
|
|
449
|
+
Bootstrap Data Generation Process (DGP) method.
|
|
450
|
+
jtest_solver : {'hybrid-ols', 'scipy', 'lmfit'}, default='hybrid-ols'
|
|
451
|
+
Computational engine used to estimate the baseline models and test parameter:
|
|
452
|
+
* 'hybrid-ols' : Hybrid approach. Baseline fits via `scipy.optimize.curve_fit`;
|
|
453
|
+
auxiliary J-test regression solved via `statsmodels.api.OLS`.
|
|
454
|
+
* 'scipy' : Pure Non-linear Least Squares (NLS) pipeline using
|
|
455
|
+
`scipy.optimize.curve_fit` for both baseline and augmented models.
|
|
456
|
+
* 'lmfit' : Symbolic, parameter-based optimization pipeline using `lmfit.Model`.
|
|
457
|
+
|
|
458
|
+
Other Parameters
|
|
459
|
+
----------------
|
|
460
|
+
**kwargs : dict
|
|
461
|
+
Solver-specific and general execution controls:
|
|
462
|
+
|
|
463
|
+
*Global / Bootstrap Controls:*
|
|
464
|
+
- res_distribution : {'rademacher', 'mammen'}, default='rademacher'
|
|
465
|
+
Auxiliary distribution for wild bootstrap resampling.
|
|
466
|
+
- boot_tails : {'one', 'two'}, default='one'
|
|
467
|
+
Tail definition for calculating the empirical bootstrap p-value.
|
|
468
|
+
- returnmodelresiduals : bool, default=False
|
|
469
|
+
If True, populates `residuals` in the returned `JtestResults`.
|
|
470
|
+
- returnfittedvalues : bool, default=False
|
|
471
|
+
If True, populates `fitted` in the returned `JtestResults`.
|
|
472
|
+
|
|
473
|
+
*Backend-Specific Controls:*
|
|
474
|
+
- p0_X : list, np.ndarray, or dict, optional
|
|
475
|
+
Initial parameter values for Model H1:
|
|
476
|
+
- `list`/`np.ndarray` for 'hybrid-ols' and 'scipy' (e.g., `[1.0, 0.5]`)
|
|
477
|
+
- `dict` for 'lmfit' (e.g., `{'alpha': 1.0, 'beta': 0.5}`)
|
|
478
|
+
- p0_Z : list, np.ndarray, or dict, optional
|
|
479
|
+
Initial parameter values for Model H2 (same formatting rules as `p0_X`).
|
|
480
|
+
- curvefitmethod : str, default='lm'
|
|
481
|
+
[*'hybrid-ols' and 'scipy' backends only*] Optimization method passed to
|
|
482
|
+
`scipy.optimize.curve_fit` (e.g., `'lm'`, `'trf'`, `'dogbox'`).
|
|
483
|
+
|
|
484
|
+
Returns
|
|
485
|
+
-------
|
|
486
|
+
JtestResults
|
|
487
|
+
Dataclass containing:
|
|
488
|
+
- t_asy (float) : Observed J-test t-statistic.
|
|
489
|
+
- p_asy (float) : Asymptotic p-value based on Student's t-distribution.
|
|
490
|
+
- p_boot (float): Empirical bootstrap p-value across valid iterations.
|
|
491
|
+
- s_its (float) : Number of successful bootstrap iterations completed.
|
|
492
|
+
- residuals (np.ndarray, optional): Null model residuals (if requested).
|
|
493
|
+
- fitted (np.ndarray, optional) : Null model fitted values (if requested).
|
|
494
|
+
|
|
495
|
+
Backend Solver Comparison
|
|
496
|
+
-------------------------
|
|
497
|
+
+--------------+-----------------------+--------------------------+----------------------------+
|
|
498
|
+
| Solver | Baseline Engine | J-Test Regression Engine | Parameter Initializer (p0) |
|
|
499
|
+
+==============+=======================+==========================+============================+
|
|
500
|
+
| 'hybrid-ols' | scipy.curve_fit | statsmodels.OLS | List / Array |
|
|
501
|
+
+--------------+-----------------------+--------------------------+----------------------------+
|
|
502
|
+
| 'scipy' | scipy.curve_fit | scipy.curve_fit (NLS) | List / Array |
|
|
503
|
+
+--------------+-----------------------+--------------------------+----------------------------+
|
|
504
|
+
| 'lmfit' | lmfit.Model | lmfit.Model | Dict ({'param': val}) |
|
|
505
|
+
+--------------+-----------------------+--------------------------+----------------------------+
|
|
506
|
+
|
|
507
|
+
Notes
|
|
508
|
+
-----
|
|
509
|
+
1. **'hybrid-ols' Backend**: Fast and robust. It converts the non-linear J-test into a linear augmented regression, avoiding full non-linear convergence issues
|
|
510
|
+
during the bootstrap loop.
|
|
511
|
+
2. **'scipy' Backend**: Preserves the non-linear nature of Model H1 in the augmented regression.
|
|
512
|
+
3. **'lmfit' Backend**: Requires the `lmfit` package. Enables parameter naming,
|
|
513
|
+
bounds, and advanced symbolic constraints, but expects keyword-based callables.
|
|
514
|
+
"""
|
|
515
|
+
return_residuals: bool = kwargs.get('return_residuals', False)
|
|
516
|
+
return_fitted: bool = kwargs.get('return_fitted', False)
|
|
517
|
+
jtest_solver: str = kwargs.get('jtest_solver', 'hybrid-ols')
|
|
518
|
+
res_distribution: str = kwargs.get('res_distribution', 'rademacher')
|
|
519
|
+
boot_tails: str = kwargs.get('boot_tails', 'one')
|
|
520
|
+
|
|
521
|
+
rng = np.random.default_rng(seed)
|
|
522
|
+
# Data prepocessing
|
|
523
|
+
y_f = np.asarray(y, dtype=np.float64).ravel()
|
|
524
|
+
N = len(y_f)
|
|
525
|
+
X_mat = np.asarray(X, dtype=np.float64)
|
|
526
|
+
Z_mat = np.asarray(Z, dtype=np.float64)
|
|
527
|
+
|
|
528
|
+
# Helper functions
|
|
529
|
+
def generate_y_star(
|
|
530
|
+
y_h_null: np.ndarray,
|
|
531
|
+
res_scaled: Optional[np.ndarray],
|
|
532
|
+
res_std: Optional[float],
|
|
533
|
+
rng_inst: np.random.Generator
|
|
534
|
+
) -> np.ndarray:
|
|
535
|
+
"""Simulate synthetic y* values under the null hypothesis (H1)."""
|
|
536
|
+
if boot_method == 'wild':
|
|
537
|
+
if res_distribution == 'rademacher':
|
|
538
|
+
v = rng_inst.choice([-1.0, 1.0], size=N, replace=True)
|
|
539
|
+
elif res_distribution == 'mammen':
|
|
540
|
+
v_choices = [-(np.sqrt(5) - 1) / 2, (np.sqrt(5) + 1) / 2]
|
|
541
|
+
v_probs = [(np.sqrt(5) + 1) / (2 * np.sqrt(5)), (np.sqrt(5) - 1) / (2 * np.sqrt(5))]
|
|
542
|
+
v = rng_inst.choice(v_choices, size=N, replace=True, p=v_probs)
|
|
543
|
+
else:
|
|
544
|
+
raise ValueError('Supported distributions for wild bootstrap: "rademacher" or "mammen".')
|
|
545
|
+
return y_h_null + res_scaled * v
|
|
546
|
+
|
|
547
|
+
elif boot_method in ['residuals', 'semiparametric']:
|
|
548
|
+
b_idx = rng.choice(N, size=N, replace=True)
|
|
549
|
+
return y_h_null + res_scaled[b_idx]
|
|
550
|
+
|
|
551
|
+
elif boot_method == 'parametric':
|
|
552
|
+
return y_h_null + rng.normal(loc=0.0, scale=res_std, size=N)
|
|
553
|
+
|
|
554
|
+
raise ValueError(f"Invalid boot_method: {boot_method}")
|
|
555
|
+
|
|
556
|
+
def calc_p_boot(t_stat_boot: list, t_stat_obs: float) -> float:
|
|
557
|
+
"""Compute the empirical bootstrap p-value."""
|
|
558
|
+
t_boot_arr = np.array(t_stat_boot)
|
|
559
|
+
s_iterations = len(t_boot_arr)
|
|
560
|
+
if s_iterations == 0:
|
|
561
|
+
return np.nan
|
|
562
|
+
|
|
563
|
+
if boot_tails == 'one':
|
|
564
|
+
return float((1 + np.sum(t_boot_arr >= t_stat_obs)) / (1 + s_iterations))
|
|
565
|
+
elif boot_tails == 'two':
|
|
566
|
+
return float((1 + np.sum(np.abs(t_boot_arr) >= np.abs(t_stat_obs))) / (1 + s_iterations))
|
|
567
|
+
else:
|
|
568
|
+
raise ValueError('boot_tails option must be "one" or "two".')
|
|
569
|
+
|
|
570
|
+
def format_cf_input(arr: np.ndarray) -> np.ndarray:
|
|
571
|
+
"""Format inputs for scipy.optimize.curve_fit."""
|
|
572
|
+
return arr.T if arr.ndim > 1 else arr
|
|
573
|
+
|
|
574
|
+
# Hybrid-OLS: Base models fitted via SciPy with statmodels.OLS augmented regression
|
|
575
|
+
if jtest_solver == 'hybrid-ols':
|
|
576
|
+
curvefitmethod = kwargs.get('curvefitmethod', 'lm')
|
|
577
|
+
p0_X = kwargs.get('p0_X', None)
|
|
578
|
+
p0_Z = kwargs.get('p0_Z', None)
|
|
579
|
+
|
|
580
|
+
def run_jtest_ols(
|
|
581
|
+
x_null: np.ndarray, x_alt: np.ndarray, f_null: Callable, f_alt: Callable, p0_null: Any, p0_alt: Any
|
|
582
|
+
) -> Tuple[float, float, float, int, np.ndarray, np.ndarray]:
|
|
583
|
+
|
|
584
|
+
x_null_cf = format_cf_input(x_null)
|
|
585
|
+
x_alt_cf = format_cf_input(x_alt)
|
|
586
|
+
# Fit base models
|
|
587
|
+
popt_null, _ = curve_fit(f_null, x_null_cf, y_f, p0=p0_null, method=curvefitmethod)
|
|
588
|
+
y_h_null = f_null(x_null_cf, *popt_null)
|
|
589
|
+
res_null = y_f - y_h_null
|
|
590
|
+
dof = N - len(popt_null)
|
|
591
|
+
|
|
592
|
+
popt_alt, _ = curve_fit(f_alt, x_alt_cf, y_f, p0=p0_alt, method=curvefitmethod)
|
|
593
|
+
y_h_alt = f_alt(x_alt_cf, *popt_alt)
|
|
594
|
+
# Augmented regression
|
|
595
|
+
X_aug = sm.add_constant(np.column_stack((x_null, y_h_alt)))
|
|
596
|
+
j_model = sm.OLS(y_f, X_aug).fit()
|
|
597
|
+
# J-statistic and asymptotic p-value
|
|
598
|
+
t_stat_obs = float(j_model.tvalues[-1])
|
|
599
|
+
p_obs = float(j_model.pvalues[-1])
|
|
600
|
+
|
|
601
|
+
# Residual scaling and bootstrap setup
|
|
602
|
+
res_scaled, res_std = None, None
|
|
603
|
+
if boot_method in ['residuals', 'semiparametric']:
|
|
604
|
+
res_scaled = (res_null - np.mean(res_null)) * np.sqrt(N / dof)
|
|
605
|
+
elif boot_method == 'wild':
|
|
606
|
+
res_scaled = res_null * np.sqrt(N / dof)
|
|
607
|
+
elif boot_method == 'parametric':
|
|
608
|
+
res_std = float(np.sqrt(np.sum(res_null**2) / dof))
|
|
609
|
+
|
|
610
|
+
# Bootstrap loop
|
|
611
|
+
t_stat_boot = []
|
|
612
|
+
for _ in range(B):
|
|
613
|
+
try:
|
|
614
|
+
if boot_method == 'pairs':
|
|
615
|
+
idx = np.random.choice(N, size=N, replace=True)
|
|
616
|
+
x_null_b = x_null[idx] if x_null.ndim == 1 else x_null[idx, :]
|
|
617
|
+
x_alt_b = x_alt[idx] if x_alt.ndim == 1 else x_alt[idx, :]
|
|
618
|
+
y_b = y_f[idx]
|
|
619
|
+
else:
|
|
620
|
+
x_null_b, x_alt_b = x_null, x_alt
|
|
621
|
+
y_b = generate_y_star(y_h_null, res_scaled, res_std)
|
|
622
|
+
|
|
623
|
+
x_null_b_cf = format_cf_input(x_null_b)
|
|
624
|
+
x_alt_b_cf = format_cf_input(x_alt_b)
|
|
625
|
+
|
|
626
|
+
popt_alt_b, _ = curve_fit(f_alt, x_alt_b_cf, y_b, p0=popt_alt, method=curvefitmethod)
|
|
627
|
+
y_h_alt_b = f_alt(x_alt_b_cf, *popt_alt_b)
|
|
628
|
+
|
|
629
|
+
X_aug_b = sm.add_constant(np.column_stack((x_null_b, y_h_alt_b)))
|
|
630
|
+
j_model_b = sm.OLS(y_b, X_aug_b).fit()
|
|
631
|
+
|
|
632
|
+
t_b = float(j_model_b.tvalues[-1])
|
|
633
|
+
t_stat_boot.append(t_b - t_stat_obs if boot_method == 'pairs' else t_b)
|
|
634
|
+
except (RuntimeError, np.linalg.LinAlgError, ValueError):
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
p_boot = calc_p_boot(t_stat_boot, t_stat_obs)
|
|
638
|
+
return t_stat_obs, p_obs, p_boot, len(t_stat_boot), res_null, y_h_null
|
|
639
|
+
|
|
640
|
+
res_m = run_jtest_ols(X_mat, Z_mat, func_X, func_Z, p0_X, p0_Z)
|
|
641
|
+
|
|
642
|
+
# SciPy: Uses SciPy for both base models and augmented regression
|
|
643
|
+
elif jtest_solver == 'scipy':
|
|
644
|
+
curvefitmethod = kwargs.get('curvefitmethod', 'lm')
|
|
645
|
+
p0_X = kwargs.get('p0_X', None)
|
|
646
|
+
p0_Z = kwargs.get('p0_Z', None)
|
|
647
|
+
|
|
648
|
+
def make_augmented_model(base_model_func: Callable, competitor_fitted: np.ndarray) -> Callable:
|
|
649
|
+
return lambda x, *terms: base_model_func(x, *terms[:-1]) + terms[-1] * competitor_fitted
|
|
650
|
+
|
|
651
|
+
def run_jtest_scipy(
|
|
652
|
+
x_null: np.ndarray, x_alt: np.ndarray, f_null: Callable, f_alt: Callable, p0_null: Any, p0_alt: Any
|
|
653
|
+
) -> Tuple[float, float, float, int, np.ndarray, np.ndarray]:
|
|
654
|
+
|
|
655
|
+
x_null_cf = format_cf_input(x_null)
|
|
656
|
+
x_alt_cf = format_cf_input(x_alt)
|
|
657
|
+
# Fit base models
|
|
658
|
+
popt_null, _ = curve_fit(f_null, x_null_cf, y_f, p0=p0_null, method=curvefitmethod)
|
|
659
|
+
y_h_null = f_null(x_null_cf, *popt_null)
|
|
660
|
+
res_null = y_f - y_h_null
|
|
661
|
+
dof = N - len(popt_null)
|
|
662
|
+
|
|
663
|
+
popt_alt, _ = curve_fit(f_alt, x_alt_cf, y_f, p0=p0_alt, method=curvefitmethod)
|
|
664
|
+
y_h_alt = f_alt(x_alt_cf, *popt_alt)
|
|
665
|
+
# Fit augmented model
|
|
666
|
+
model_aug = make_augmented_model(f_null, y_h_alt)
|
|
667
|
+
popt_aug, pcov_aug = curve_fit(model_aug, x_null_cf, y_f, p0=[*popt_null, 0.0], method=curvefitmethod)
|
|
668
|
+
# J-statistic and asymptotic p-value
|
|
669
|
+
t_stat_obs = float(popt_aug[-1] / np.sqrt(pcov_aug[-1, -1]))
|
|
670
|
+
p_obs = float(stats.t.sf(np.abs(t_stat_obs), df=dof - 1) * 2)
|
|
671
|
+
|
|
672
|
+
#Residual scaling and bootstrap setup
|
|
673
|
+
res_scaled, res_std = None, None
|
|
674
|
+
if boot_method in ['residuals', 'semiparametric']:
|
|
675
|
+
res_scaled = (res_null - np.mean(res_null)) * np.sqrt(N / dof)
|
|
676
|
+
elif boot_method == 'wild':
|
|
677
|
+
res_scaled = res_null * np.sqrt(N / dof)
|
|
678
|
+
elif boot_method == 'parametric':
|
|
679
|
+
res_std = float(np.sqrt(np.sum(res_null**2) / dof))
|
|
680
|
+
|
|
681
|
+
#Bootstrap loop
|
|
682
|
+
t_stat_boot = []
|
|
683
|
+
for _ in range(B):
|
|
684
|
+
try:
|
|
685
|
+
if boot_method == 'pairs':
|
|
686
|
+
idx = rng.choice(N, size=N, replace=True)
|
|
687
|
+
x_null_b = x_null[idx] if x_null.ndim == 1 else x_null[idx, :]
|
|
688
|
+
x_alt_b = x_alt[idx] if x_alt.ndim == 1 else x_alt[idx, :]
|
|
689
|
+
y_b = y_f[idx]
|
|
690
|
+
else:
|
|
691
|
+
x_null_b, x_alt_b = x_null, x_alt
|
|
692
|
+
y_b = generate_y_star(y_h_null, res_scaled, res_std, rng)
|
|
693
|
+
|
|
694
|
+
x_null_b_cf = format_cf_input(x_null_b)
|
|
695
|
+
x_alt_b_cf = format_cf_input(x_alt_b)
|
|
696
|
+
|
|
697
|
+
popt_alt_b, _ = curve_fit(f_alt, x_alt_b_cf, y_b, p0=popt_alt, method=curvefitmethod)
|
|
698
|
+
y_h_alt_b = f_alt(x_alt_b_cf, *popt_alt_b)
|
|
699
|
+
|
|
700
|
+
model_aug_b = make_augmented_model(f_null, y_h_alt_b)
|
|
701
|
+
popt_aug_b, pcov_aug_b = curve_fit(model_aug_b, x_null_b_cf, y_b, p0=[*popt_null, 0.0], method=curvefitmethod)
|
|
702
|
+
|
|
703
|
+
t_b = float(popt_aug_b[-1] / np.sqrt(pcov_aug_b[-1, -1]))
|
|
704
|
+
t_stat_boot.append(t_b - t_stat_obs if boot_method == 'pairs' else t_b)
|
|
705
|
+
except (RuntimeError, ValueError):
|
|
706
|
+
continue
|
|
707
|
+
|
|
708
|
+
p_boot = calc_p_boot(t_stat_boot, t_stat_obs)
|
|
709
|
+
return t_stat_obs, p_obs, p_boot, len(t_stat_boot), res_null, y_h_null
|
|
710
|
+
|
|
711
|
+
res_m = run_jtest_scipy(X_mat, Z_mat, func_X, func_Z, p0_X, p0_Z)
|
|
712
|
+
|
|
713
|
+
# LMFIT: Base models and augmented regressions via lmfit.Model
|
|
714
|
+
elif jtest_solver == 'lmfit':
|
|
715
|
+
if not HAS_LMFIT:
|
|
716
|
+
raise ImportError("The 'lmfit' package is required for jtest_solver='lmfit'. Install via 'pip install lmfit'.")
|
|
717
|
+
|
|
718
|
+
p0_X: Dict[str, float] = kwargs.get('p0_X', {})
|
|
719
|
+
p0_Z: Dict[str, float] = kwargs.get('p0_Z', {})
|
|
720
|
+
|
|
721
|
+
def run_jtest_lmfit(
|
|
722
|
+
x_null: np.ndarray, x_alt: np.ndarray, f_null: Callable, f_alt: Callable, p0_null: dict, p0_alt: dict
|
|
723
|
+
) -> Tuple[float, float, float, int, np.ndarray, np.ndarray]:
|
|
724
|
+
|
|
725
|
+
x_null_cf = format_cf_input(x_null)
|
|
726
|
+
x_alt_cf = format_cf_input(x_alt)
|
|
727
|
+
|
|
728
|
+
# Define lmfit models
|
|
729
|
+
model_null, model_alt = Model(f_null), Model(f_alt)
|
|
730
|
+
params_null, params_alt = model_null.make_params(), model_alt.make_params()
|
|
731
|
+
|
|
732
|
+
for k, v in p0_null.items():
|
|
733
|
+
if k in params_null: params_null[k].set(value=v)
|
|
734
|
+
for k, v in p0_alt.items():
|
|
735
|
+
if k in params_alt: params_alt[k].set(value=v)
|
|
736
|
+
|
|
737
|
+
#Fit base models
|
|
738
|
+
res_null = model_null.fit(y_f, x=x_null_cf, params=params_null)
|
|
739
|
+
res_alt = model_alt.fit(y_f, x=x_alt_cf, params=params_alt)
|
|
740
|
+
|
|
741
|
+
y_h_null = res_null.best_fit
|
|
742
|
+
res_null_arr = res_null.residual
|
|
743
|
+
dof = N - res_null.nvarys
|
|
744
|
+
comp_fit_vals = res_alt.best_fit
|
|
745
|
+
# Augmented model
|
|
746
|
+
def make_lmfit_augmented(base_func: Callable, fitted_alt: np.ndarray) -> Callable:
|
|
747
|
+
return lambda x, lam=0.0, **params: base_func(x, **params) + lam * fitted_alt
|
|
748
|
+
# Fit augmented model
|
|
749
|
+
aug_func = make_lmfit_augmented(f_null, comp_fit_vals)
|
|
750
|
+
model_aug = Model(aug_func)
|
|
751
|
+
aug_params = res_null.params.copy()
|
|
752
|
+
aug_params.add('lam', value=0.0)
|
|
753
|
+
# J-statistic and asymptotic p-value
|
|
754
|
+
res_aug = model_aug.fit(y_f, x=x_null_cf, params=aug_params)
|
|
755
|
+
lam_se = res_aug.params['lam'].stderr
|
|
756
|
+
t_stat_obs = float(res_aug.params['lam'].value / lam_se) if lam_se else np.nan
|
|
757
|
+
p_obs = float(stats.t.sf(np.abs(t_stat_obs), df=res_aug.df_intrinsic) * 2) if not np.isnan(t_stat_obs) else 1.0
|
|
758
|
+
|
|
759
|
+
# Residual scaling and bootstrap setup
|
|
760
|
+
res_scaled, res_std = None, None
|
|
761
|
+
if boot_method in ['residuals', 'semiparametric']:
|
|
762
|
+
res_scaled = (res_null_arr - np.mean(res_null_arr)) * np.sqrt(N / dof)
|
|
763
|
+
elif boot_method == 'wild':
|
|
764
|
+
res_scaled = res_null_arr * np.sqrt(N / dof)
|
|
765
|
+
elif boot_method == 'parametric':
|
|
766
|
+
res_std = float(np.sqrt(np.sum(res_null_arr**2) / dof))
|
|
767
|
+
|
|
768
|
+
# Bootstrap loop
|
|
769
|
+
t_stat_boot = []
|
|
770
|
+
for _ in range(B):
|
|
771
|
+
try:
|
|
772
|
+
if boot_method == 'pairs':
|
|
773
|
+
idx = rng.choice(N, size=N, replace=True)
|
|
774
|
+
x_null_b = x_null[idx] if x_null.ndim == 1 else x_null[idx, :]
|
|
775
|
+
x_alt_b = x_alt[idx] if x_alt.ndim == 1 else x_alt[idx, :]
|
|
776
|
+
y_b = y_f[idx]
|
|
777
|
+
else:
|
|
778
|
+
x_null_b, x_alt_b = x_null, x_alt
|
|
779
|
+
y_b = generate_y_star(y_h_null, res_scaled, res_std)
|
|
780
|
+
|
|
781
|
+
x_null_b_cf = format_cf_input(x_null_b)
|
|
782
|
+
x_alt_b_cf = format_cf_input(x_alt_b)
|
|
783
|
+
|
|
784
|
+
res_alt_b = model_alt.fit(y_b, x=x_alt_b_cf, params=res_alt.params.copy())
|
|
785
|
+
aug_func_b = make_lmfit_augmented(f_null, res_alt_b.best_fit)
|
|
786
|
+
|
|
787
|
+
model_aug_b = Model(aug_func_b)
|
|
788
|
+
aug_params_b = res_null.params.copy()
|
|
789
|
+
aug_params_b.add('lam', value=0.0)
|
|
790
|
+
|
|
791
|
+
res_aug_b = model_aug_b.fit(y_b, x=x_null_b_cf, params=aug_params_b)
|
|
792
|
+
lam_se_b = res_aug_b.params['lam'].stderr
|
|
793
|
+
t_b = float(res_aug_b.params['lam'].value / lam_se_b) if lam_se_b else np.nan
|
|
794
|
+
|
|
795
|
+
if not np.isnan(t_b):
|
|
796
|
+
t_stat_boot.append(t_b - t_stat_obs if boot_method == 'pairs' else t_b)
|
|
797
|
+
except Exception:
|
|
798
|
+
continue
|
|
799
|
+
|
|
800
|
+
p_boot = calc_p_boot(t_stat_boot, t_stat_obs)
|
|
801
|
+
return t_stat_obs, p_obs, p_boot, len(t_stat_boot), res_null_arr, y_h_null
|
|
802
|
+
|
|
803
|
+
res_m = run_jtest_lmfit(X_mat, Z_mat, func_X, func_Z, p0_X, p0_Z)
|
|
804
|
+
|
|
805
|
+
else:
|
|
806
|
+
raise ValueError(f"Unknown solver '{jtest_solver}'. Supported options: 'hybrid-ols', 'scipy', 'lmfit'.")
|
|
807
|
+
|
|
808
|
+
# Outputs
|
|
809
|
+
t_stat_obs, p_obs, p_boot, s_its, res_null, y_h_null = res_m
|
|
810
|
+
|
|
811
|
+
return JtestResults(
|
|
812
|
+
t_asy=float(t_stat_obs),
|
|
813
|
+
p_asy=float(p_obs),
|
|
814
|
+
p_boot=float(p_boot),
|
|
815
|
+
s_its=int(s_its),
|
|
816
|
+
residuals=np.asarray(res_null) if return_residuals else None,
|
|
817
|
+
fitted=np.asarray(y_h_null) if return_fitted else None
|
|
818
|
+
)
|
|
819
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bdmjtest
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Bootstrapped Davidson-MacKinnon J-test for linear and non-linear regression models
|
|
5
|
+
Project-URL: Homepage, https://github.com/santiagohigareda/bdmjtest
|
|
6
|
+
Project-URL: Wiki, https://github.com/santiagohigareda/bdmjtest/wiki
|
|
7
|
+
Author-email: Santiago Guerrero-Higareda <s.g.higareda@proton.me>
|
|
8
|
+
Maintainer-email: Santiago Guerrero-Higareda <s.g.higareda@proton.me>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: bootstrapping,econometrics,statistics
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Operating System :: POSIX
|
|
19
|
+
Classifier: Operating System :: Unix
|
|
20
|
+
Classifier: Programming Language :: Python
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Requires-Python: >=3.13
|
|
28
|
+
Requires-Dist: lmfit>=1.3.4
|
|
29
|
+
Requires-Dist: numpy>=2.1.0
|
|
30
|
+
Requires-Dist: scipy>=1.14.1
|
|
31
|
+
Requires-Dist: statsmodels>=0.15.0
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# bdmjtest: Bootstrapped Davidson-MacKinnon J-test
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/bdmjtest/)
|
|
37
|
+
[](https://pypi.org/project/bdmjtest/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
`bdmjtest` is an open-open source Python package that provides a high-performance Bootstrapped J-test implementation for comparing non-nested linear and non-linear models.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
- **Multiple Bootstrap DGPs**: Support residuals (semiparametric), parametric, wild and pairs (nonparametric) bootstrap Data Generation Processes (DGPs) to handle different error structures (e.g. non-normal, skewed or heavy-tailed).
|
|
44
|
+
- **Three Backend Engines**: Choose between lightweight NumPy (`jtest_np`), Statsmodels (`jtest_sm`), or SciPy (`jtest_scipy`), based on the characteristics and requirements of the models and data being tested.
|
|
45
|
+
- **Standardized Output**: Returns an immutable 'JtestsResults' object containing the asymptotic J-test t-statistic, asymptotic p-value, bootstrap p-value, valid iteration counts and optional series (H<sub>1</sub> residuals and fitted values).
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
```
|
|
49
|
+
pip install bdmjtest
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
The test must be run pairwise (H<sub>1</sub> vs H<sub>2</sub>, and H<sub>2</sub> vs H<sub>1</sub>).
|
|
54
|
+
The following example is for testing one linear model (H1) against another (H2) using the NumPy backend.
|
|
55
|
+
```
|
|
56
|
+
import numpy as np
|
|
57
|
+
import bdmjtest as jtest
|
|
58
|
+
|
|
59
|
+
# Define your dependent variable (y) and explanatory variables for H1 (X) and H2 (Z)
|
|
60
|
+
y = np.random.rand(100)
|
|
61
|
+
X = np.random.rand(100, 1)
|
|
62
|
+
Z = np.random.rand(100, 1)
|
|
63
|
+
|
|
64
|
+
# Perform the bootstrapped J-test
|
|
65
|
+
# H1 vs H2
|
|
66
|
+
res_1 = jtest.jtest_np(y, X, Z, boot_method='residuals', B=999)
|
|
67
|
+
# H2 vs H1
|
|
68
|
+
res_2 = jtest.jtest_np(y, Z, X, boot_method='residuals', B=999)
|
|
69
|
+
|
|
70
|
+
# Results
|
|
71
|
+
print(f"H1 vs H2 - Bootstrapped p-value: {res_1.p_boot}")
|
|
72
|
+
print(f"H2 vs H1 - Bootstrapped p-value: {res_2.p_boot}")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Citation
|
|
76
|
+
If you use `bdmjtest` in a published work, please cite as:
|
|
77
|
+
APA 7
|
|
78
|
+
```
|
|
79
|
+
Guerrero-Higareda, S. (2026). bdmjtest: A Python Package for Bootstrapped Davidson-MacKinnon J-test in Linear and Non-Linear Non-Nested Models (Version 1.0.1) [Computer Software]. https://github.com/santiagohigareda/bdmjtest
|
|
80
|
+
```
|
|
81
|
+
BibTex
|
|
82
|
+
```
|
|
83
|
+
@software{bdmjtest2026,
|
|
84
|
+
author = {Guerrero-Higareda, Santiago},
|
|
85
|
+
title = {bdmjtest: A Python Package for Bootstrapped Davidson-MacKinnon J-test in Linear and Non-Linear Non-Nested Models},
|
|
86
|
+
year = {2026},
|
|
87
|
+
version = {1.0.1},
|
|
88
|
+
url = {https://github.com/santiagohigareda/bdmjtest}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Support and contributing
|
|
92
|
+
Contributions are welcome! Questions, bugs reports and feature requests go to [GitHub Issues](https://github.com/santiagohigareda/bdmjtest/issues). If you don´t have a GitHub account or have a particular inquiry, [email me](mailto:s.g.higareda@proton.me).
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
Copyright (c) 2026 Santiago Guerrero-Higareda
|
|
97
|
+
|
|
98
|
+
This project is licensed under the MIT License. See [LICENSE](https://github.com/santiagohigareda/bdmjtest/blob/main/LICENSE) for details.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
bdmjtest/__init__.py,sha256=JiliGeYyPA7DmSYHotJhIBHr3WPDHrVBHjFFCu9HIV8,321
|
|
2
|
+
bdmjtest/_version.py,sha256=A5VYxos8xGae_AHOBEJdlvczVIgb04eWU9K0NrOvyy4,520
|
|
3
|
+
bdmjtest/bdmjtest.py,sha256=nUpjJRGaJyqNPWFmmKxtYFOlmQLzsr45869zhXjhisg,36551
|
|
4
|
+
bdmjtest-1.0.1.dist-info/METADATA,sha256=5hiZvPuuImJYfR6faJK-OCPaVskSIap8Uc9TE0srQU4,4592
|
|
5
|
+
bdmjtest-1.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
bdmjtest-1.0.1.dist-info/RECORD,,
|