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.
- bdmjtest-1.0.1/.github/workflows/python-publish.yml +54 -0
- bdmjtest-1.0.1/LICENSE +21 -0
- bdmjtest-1.0.1/PKG-INFO +98 -0
- bdmjtest-1.0.1/README.md +65 -0
- bdmjtest-1.0.1/pyproject.toml +56 -0
- bdmjtest-1.0.1/src/bdmjtest/__init__.py +10 -0
- bdmjtest-1.0.1/src/bdmjtest/_version.py +24 -0
- bdmjtest-1.0.1/src/bdmjtest/bdmjtest.py +819 -0
|
@@ -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.
|
bdmjtest-1.0.1/PKG-INFO
ADDED
|
@@ -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.
|
bdmjtest-1.0.1/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# bdmjtest: Bootstrapped Davidson-MacKinnon J-test
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/bdmjtest/)
|
|
4
|
+
[](https://pypi.org/project/bdmjtest/)
|
|
5
|
+
[](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
|
+
|