bdmjtest 1.0.1__tar.gz

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.
@@ -0,0 +1,54 @@
1
+ name: Upload Python Package
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ release-build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0 # For hatch-vcs
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.13"
22
+
23
+ - name: Build release distributions
24
+ run: |
25
+ python -m pip install hatch
26
+ hatch build
27
+
28
+ - name: Upload distributions
29
+ uses: actions/upload-artifact@v4
30
+ with:
31
+ name: release-dists
32
+ path: dist/
33
+
34
+ pypi-publish:
35
+ runs-on: ubuntu-latest
36
+ needs:
37
+ - release-build
38
+ permissions:
39
+ # IMPORTANT: this permission is mandatory for trusted publishing
40
+ id-token: write
41
+ environment:
42
+ name: pypi
43
+
44
+ steps:
45
+ - name: Retrieve release distributions
46
+ uses: actions/download-artifact@v4
47
+ with:
48
+ name: release-dists
49
+ path: dist/
50
+
51
+ - name: Publish release distributions to PyPI
52
+ uses: pypa/gh-action-pypi-publish@release/v1
53
+ with:
54
+ packages-dir: dist/
bdmjtest-1.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Santiago Guerrero-Higareda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -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
+ [![Development Status](https://img.shields.io/pypi/status/bdmjtest)](https://pypi.org/project/bdmjtest/)
37
+ [![PyPI version](https://shields.io/pypi/v/bdmjtest)](https://pypi.org/project/bdmjtest/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,65 @@
1
+ # bdmjtest: Bootstrapped Davidson-MacKinnon J-test
2
+
3
+ [![Development Status](https://img.shields.io/pypi/status/bdmjtest)](https://pypi.org/project/bdmjtest/)
4
+ [![PyPI version](https://shields.io/pypi/v/bdmjtest)](https://pypi.org/project/bdmjtest/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ `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.
8
+
9
+ ## Features
10
+ - **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).
11
+ - **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.
12
+ - **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).
13
+
14
+ ## Installation
15
+ ```
16
+ pip install bdmjtest
17
+ ```
18
+
19
+ ## Usage
20
+ 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>).
21
+ The following example is for testing one linear model (H1) against another (H2) using the NumPy backend.
22
+ ```
23
+ import numpy as np
24
+ import bdmjtest as jtest
25
+
26
+ # Define your dependent variable (y) and explanatory variables for H1 (X) and H2 (Z)
27
+ y = np.random.rand(100)
28
+ X = np.random.rand(100, 1)
29
+ Z = np.random.rand(100, 1)
30
+
31
+ # Perform the bootstrapped J-test
32
+ # H1 vs H2
33
+ res_1 = jtest.jtest_np(y, X, Z, boot_method='residuals', B=999)
34
+ # H2 vs H1
35
+ res_2 = jtest.jtest_np(y, Z, X, boot_method='residuals', B=999)
36
+
37
+ # Results
38
+ print(f"H1 vs H2 - Bootstrapped p-value: {res_1.p_boot}")
39
+ print(f"H2 vs H1 - Bootstrapped p-value: {res_2.p_boot}")
40
+ ```
41
+
42
+ ## Citation
43
+ If you use `bdmjtest` in a published work, please cite as:
44
+ APA 7
45
+ ```
46
+ 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
47
+ ```
48
+ BibTex
49
+ ```
50
+ @software{bdmjtest2026,
51
+ author = {Guerrero-Higareda, Santiago},
52
+ title = {bdmjtest: A Python Package for Bootstrapped Davidson-MacKinnon J-test in Linear and Non-Linear Non-Nested Models},
53
+ year = {2026},
54
+ version = {1.0.1},
55
+ url = {https://github.com/santiagohigareda/bdmjtest}
56
+ ```
57
+
58
+ ## Support and contributing
59
+ 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).
60
+
61
+ ## License
62
+
63
+ Copyright (c) 2026 Santiago Guerrero-Higareda
64
+
65
+ This project is licensed under the MIT License. See [LICENSE](https://github.com/santiagohigareda/bdmjtest/blob/main/LICENSE) for details.
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bdmjtest"
7
+ dynamic = ["version"]
8
+ description = "Bootstrapped Davidson-MacKinnon J-test for linear and non-linear regression models"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ license-files = ["LICEN[CS]E.*"]
12
+ requires-python = ">=3.13"
13
+ authors = [
14
+ {name = "Santiago Guerrero-Higareda", email = "s.g.higareda@proton.me"}
15
+ ]
16
+ maintainers = [
17
+ {name = "Santiago Guerrero-Higareda", email = "s.g.higareda@proton.me"}
18
+ ]
19
+ keywords = ["statistics", "econometrics", "bootstrapping"]
20
+ classifiers = [
21
+ "Development Status :: 3 - Alpha",
22
+ "Intended Audience :: Science/Research",
23
+ "Intended Audience :: Financial and Insurance Industry",
24
+ "Intended Audience :: Developers",
25
+ "Topic :: Scientific/Engineering :: Mathematics",
26
+ "Topic :: Scientific/Engineering :: Information Analysis",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Programming Language :: Python",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3 :: Only",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Operating System :: OS Independent",
33
+ "Operating System :: Microsoft :: Windows",
34
+ "Operating System :: POSIX",
35
+ "Operating System :: Unix",
36
+ "Operating System :: MacOS",
37
+ ]
38
+ dependencies = [
39
+ "numpy>=2.1.0",
40
+ "scipy>=1.14.1",
41
+ "statsmodels>=0.15.0",
42
+ "lmfit>=1.3.4",
43
+ ]
44
+
45
+ [project.urls]
46
+ "Homepage" = "https://github.com/santiagohigareda/bdmjtest"
47
+ "Wiki" = "https://github.com/santiagohigareda/bdmjtest/wiki"
48
+
49
+ [tool.hatch.version]
50
+ source = "vcs"
51
+
52
+ [tool.hatch.build.hooks.vcs]
53
+ version-file = "src/bdmjtest/_version.py"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/bdmjtest"]
@@ -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"]
@@ -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
@@ -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
+