openpls-engine 1.0.2__tar.gz → 1.2.0__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.
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/PKG-INFO +29 -8
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/README.md +27 -6
- openpls_engine-1.2.0/openpls/copula.py +247 -0
- openpls_engine-1.2.0/openpls/cta.py +209 -0
- openpls_engine-1.2.0/openpls/htmt2.py +142 -0
- openpls_engine-1.2.0/openpls/plsc.py +205 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/plspm.py +139 -0
- openpls_engine-1.2.0/openpls/vif.py +134 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/PKG-INFO +29 -8
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/SOURCES.txt +11 -1
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/pyproject.toml +2 -2
- openpls_engine-1.2.0/tests/test_copula.py +220 -0
- openpls_engine-1.2.0/tests/test_cta.py +197 -0
- openpls_engine-1.2.0/tests/test_htmt2.py +164 -0
- openpls_engine-1.2.0/tests/test_plsc.py +218 -0
- openpls_engine-1.2.0/tests/test_vif.py +112 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/LICENSE +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/__init__.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/bootstrap.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/config.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/estimator.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/fimix.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/fit.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/htmt.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/inner_model.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/inner_summary.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/ipma.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/long_bootstrap.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/mga.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/mode.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/moderation.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/outer_model.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/predict.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/q_squared.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/scale.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/scheme.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/unidimensionality.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/util.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/weights.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/dependency_links.txt +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/requires.txt +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/top_level.txt +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/setup.cfg +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_config.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_estimator.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_fimix.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_fit.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_htmt.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_inner_summary_metrics.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_ipma.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_long_bootstrap.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_lv_mv_name_collision.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_mga.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_missing_strategy.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_moderation.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_path_recovery_synthetic.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_predict.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_q_squared.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_redundancy_analysis_mode_b.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_regression_bootstrap.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_regression_metric.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_regression_nonmetric.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_regression_seminr.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_scheme_equivalence_two_lv.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_scheme_newton.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_scheme_pca.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_sign_convention.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_unidimensionality_fallback.py +0 -0
- {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_util.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openpls-engine
|
|
3
|
-
Version: 1.0
|
|
4
|
-
Summary: PLS-SEM compute engine, a maintained fork of plspm-python with advanced analyses (IPMA, PLSpredict, moderation, FIMIX-PLS) and extended metrics (SRMR, d_ULS, HTMT, Q², MGA) powering OpenPLS.
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: PLS-SEM compute engine, a maintained fork of plspm-python with advanced analyses (PLSc, IPMA, PLSpredict, moderation, FIMIX-PLS, Gaussian-copula endogeneity) and extended metrics (SRMR, d_ULS, HTMT, HTMT2, Q², MGA) powering OpenPLS.
|
|
5
5
|
Author-email: Johannes Jacob <johannespjacob@gmail.com>
|
|
6
6
|
License: GPL-3.0-or-later
|
|
7
7
|
Project-URL: Homepage, https://github.com/jojacobsen/openpls-engine
|
|
@@ -35,6 +35,8 @@ Dynamic: license-file
|
|
|
35
35
|
[](https://pypi.org/project/openpls-engine/)
|
|
36
36
|
[](https://pypi.org/project/openpls-engine/)
|
|
37
37
|
[](LICENSE)
|
|
38
|
+
[](https://doi.org/10.5281/zenodo.20509385)
|
|
39
|
+
[](https://doi.org/10.5281/zenodo.20511533)
|
|
38
40
|
|
|
39
41
|
The compute engine behind [OpenPLS](https://openpls.app), a Python 3 library
|
|
40
42
|
for Partial Least Squares Structural Equation Modeling (PLS-SEM).
|
|
@@ -42,12 +44,19 @@ for Partial Least Squares Structural Equation Modeling (PLS-SEM).
|
|
|
42
44
|
`openpls-engine` is a fork of
|
|
43
45
|
[`plspm-python`](https://github.com/googlecloudplatform/plspm-python) by Jez
|
|
44
46
|
Humble (Google). It keeps the original algorithm intact and adds the metrics
|
|
45
|
-
and quality criteria that modern PLS-SEM reporting requires: HTMT
|
|
47
|
+
and quality criteria that modern PLS-SEM reporting requires: HTMT (and
|
|
48
|
+
HTMT2 geometric-mean variant), SRMR,
|
|
46
49
|
d_ULS, adjusted R², BIC, Stone-Geisser Q², Cronbach α, Dijkstra-Henseler ρ,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
VIF (per-indicator and per-predictor), CTA-PLS (confirmatory tetrad
|
|
51
|
+
analysis for reflective measurement), multi-group analysis, and a
|
|
52
|
+
progress-streaming long bootstrap. It also ships
|
|
53
|
+
**advanced** analyses that go beyond what `plspm-python` covers: PLSc
|
|
54
|
+
(consistent PLS, Dijkstra & Henseler 2015) for measurement-error
|
|
55
|
+
attenuation correction in reflective models, PLSpredict out-of-sample
|
|
56
|
+
validation, IPMA (Importance-Performance Map Analysis),
|
|
57
|
+
two-stage moderation, FIMIX-PLS finite-mixture segmentation, the
|
|
58
|
+
Gaussian-copula endogeneity test (Park & Gupta 2012; Hult et al. 2018),
|
|
59
|
+
and a
|
|
51
60
|
**quasi-Newton (BFGS) inner-weighting scheme** and a **Lohmöller PCA
|
|
52
61
|
scheme**, two new alternatives to the classical centroid, factorial, and
|
|
53
62
|
path schemes (five inner-weighting schemes total).
|
|
@@ -61,6 +70,18 @@ phase of the roadmap.
|
|
|
61
70
|
> with reference implementations is tracked in [TODO.md](TODO.md). See
|
|
62
71
|
> [CHANGELOG.md](CHANGELOG.md) for the version history.
|
|
63
72
|
|
|
73
|
+
## Documentation
|
|
74
|
+
|
|
75
|
+
Hosted docs at [**openpls.app/engine**](https://openpls.app/engine):
|
|
76
|
+
|
|
77
|
+
- [Introduction](https://openpls.app/engine/) - what the library does and who it is for.
|
|
78
|
+
- [Installation](https://openpls.app/engine/installation/) - pip and source installs.
|
|
79
|
+
- [Quickstart](https://openpls.app/engine/quickstart/) - end-to-end fit on the satisfaction dataset.
|
|
80
|
+
- [Core concepts](https://openpls.app/engine/concepts/) - PLS-SEM, Mode A/B, inner-weighting schemes, missing-value strategies.
|
|
81
|
+
- [API reference](https://openpls.app/engine/api/) - the full public surface (Plspm, Config, VIF, CTAPLS, PLSc, GaussianCopula, IPMA, PLSpredict, Moderation, FIMIX, MGA, LongBootstrap).
|
|
82
|
+
- [Examples](https://openpls.app/engine/examples/) - runnable snippets for each advanced analysis.
|
|
83
|
+
- [Changelog](https://openpls.app/engine/changelog/) - version history with per-feature notes.
|
|
84
|
+
|
|
64
85
|
## Why fork
|
|
65
86
|
|
|
66
87
|
* Upstream has not seen a release since 2020.
|
|
@@ -81,7 +102,7 @@ pip install openpls-engine
|
|
|
81
102
|
Or pin a specific version for reproducible analyses:
|
|
82
103
|
|
|
83
104
|
```sh
|
|
84
|
-
pip install openpls-engine==1.0
|
|
105
|
+
pip install openpls-engine==1.2.0
|
|
85
106
|
```
|
|
86
107
|
|
|
87
108
|
To work from source instead:
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
[](https://pypi.org/project/openpls-engine/)
|
|
5
5
|
[](https://pypi.org/project/openpls-engine/)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](https://doi.org/10.5281/zenodo.20509385)
|
|
8
|
+
[](https://doi.org/10.5281/zenodo.20511533)
|
|
7
9
|
|
|
8
10
|
The compute engine behind [OpenPLS](https://openpls.app), a Python 3 library
|
|
9
11
|
for Partial Least Squares Structural Equation Modeling (PLS-SEM).
|
|
@@ -11,12 +13,19 @@ for Partial Least Squares Structural Equation Modeling (PLS-SEM).
|
|
|
11
13
|
`openpls-engine` is a fork of
|
|
12
14
|
[`plspm-python`](https://github.com/googlecloudplatform/plspm-python) by Jez
|
|
13
15
|
Humble (Google). It keeps the original algorithm intact and adds the metrics
|
|
14
|
-
and quality criteria that modern PLS-SEM reporting requires: HTMT
|
|
16
|
+
and quality criteria that modern PLS-SEM reporting requires: HTMT (and
|
|
17
|
+
HTMT2 geometric-mean variant), SRMR,
|
|
15
18
|
d_ULS, adjusted R², BIC, Stone-Geisser Q², Cronbach α, Dijkstra-Henseler ρ,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
VIF (per-indicator and per-predictor), CTA-PLS (confirmatory tetrad
|
|
20
|
+
analysis for reflective measurement), multi-group analysis, and a
|
|
21
|
+
progress-streaming long bootstrap. It also ships
|
|
22
|
+
**advanced** analyses that go beyond what `plspm-python` covers: PLSc
|
|
23
|
+
(consistent PLS, Dijkstra & Henseler 2015) for measurement-error
|
|
24
|
+
attenuation correction in reflective models, PLSpredict out-of-sample
|
|
25
|
+
validation, IPMA (Importance-Performance Map Analysis),
|
|
26
|
+
two-stage moderation, FIMIX-PLS finite-mixture segmentation, the
|
|
27
|
+
Gaussian-copula endogeneity test (Park & Gupta 2012; Hult et al. 2018),
|
|
28
|
+
and a
|
|
20
29
|
**quasi-Newton (BFGS) inner-weighting scheme** and a **Lohmöller PCA
|
|
21
30
|
scheme**, two new alternatives to the classical centroid, factorial, and
|
|
22
31
|
path schemes (five inner-weighting schemes total).
|
|
@@ -30,6 +39,18 @@ phase of the roadmap.
|
|
|
30
39
|
> with reference implementations is tracked in [TODO.md](TODO.md). See
|
|
31
40
|
> [CHANGELOG.md](CHANGELOG.md) for the version history.
|
|
32
41
|
|
|
42
|
+
## Documentation
|
|
43
|
+
|
|
44
|
+
Hosted docs at [**openpls.app/engine**](https://openpls.app/engine):
|
|
45
|
+
|
|
46
|
+
- [Introduction](https://openpls.app/engine/) - what the library does and who it is for.
|
|
47
|
+
- [Installation](https://openpls.app/engine/installation/) - pip and source installs.
|
|
48
|
+
- [Quickstart](https://openpls.app/engine/quickstart/) - end-to-end fit on the satisfaction dataset.
|
|
49
|
+
- [Core concepts](https://openpls.app/engine/concepts/) - PLS-SEM, Mode A/B, inner-weighting schemes, missing-value strategies.
|
|
50
|
+
- [API reference](https://openpls.app/engine/api/) - the full public surface (Plspm, Config, VIF, CTAPLS, PLSc, GaussianCopula, IPMA, PLSpredict, Moderation, FIMIX, MGA, LongBootstrap).
|
|
51
|
+
- [Examples](https://openpls.app/engine/examples/) - runnable snippets for each advanced analysis.
|
|
52
|
+
- [Changelog](https://openpls.app/engine/changelog/) - version history with per-feature notes.
|
|
53
|
+
|
|
33
54
|
## Why fork
|
|
34
55
|
|
|
35
56
|
* Upstream has not seen a release since 2020.
|
|
@@ -50,7 +71,7 @@ pip install openpls-engine
|
|
|
50
71
|
Or pin a specific version for reproducible analyses:
|
|
51
72
|
|
|
52
73
|
```sh
|
|
53
|
-
pip install openpls-engine==1.0
|
|
74
|
+
pip install openpls-engine==1.2.0
|
|
54
75
|
```
|
|
55
76
|
|
|
56
77
|
To work from source instead:
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2026 Johannes Jacob / OpenPLS
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
from scipy import stats
|
|
21
|
+
|
|
22
|
+
import openpls.config as c
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _copula_term(x: np.ndarray) -> np.ndarray:
|
|
26
|
+
"""Park & Gupta (2012) Gaussian copula augmentation term.
|
|
27
|
+
|
|
28
|
+
Constructs ``P = Phi^{-1}(F_n(x))``, where ``F_n`` is the empirical
|
|
29
|
+
CDF rescaled to ``(0, 1)`` via ``rank / (n + 1)`` to keep the
|
|
30
|
+
transformation finite at the boundaries.
|
|
31
|
+
"""
|
|
32
|
+
n = x.shape[0]
|
|
33
|
+
if n < 3:
|
|
34
|
+
raise ValueError("Gaussian copula requires at least 3 observations")
|
|
35
|
+
ranks = stats.rankdata(x, method="average")
|
|
36
|
+
u = ranks / (n + 1.0)
|
|
37
|
+
return stats.norm.ppf(u)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ols(y: np.ndarray, x: np.ndarray) -> np.ndarray:
|
|
41
|
+
"""OLS coefficient vector for ``y ~ [1, x]``. Returns coefficients
|
|
42
|
+
excluding the intercept."""
|
|
43
|
+
n = y.shape[0]
|
|
44
|
+
design = np.column_stack([np.ones(n), x])
|
|
45
|
+
beta, *_ = np.linalg.lstsq(design, y, rcond=None)
|
|
46
|
+
return beta[1:]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GaussianCopula:
|
|
50
|
+
"""Gaussian copula approach for endogeneity in PLS-SEM
|
|
51
|
+
(Park & Gupta 2012; Hult, Hair, Proksch, Sarstedt, Pinkwart &
|
|
52
|
+
Ringle 2018).
|
|
53
|
+
|
|
54
|
+
For a chosen endogenous latent variable ``Y`` with predecessor LVs
|
|
55
|
+
``X_1, ..., X_p``, each suspected endogenous predictor ``X_k`` is
|
|
56
|
+
augmented in the structural regression with a copula term
|
|
57
|
+
``P_k = Phi^{-1}(F_n(X_k))``. A statistically significant copula
|
|
58
|
+
coefficient ``gamma_k`` indicates that ``X_k`` is correlated with
|
|
59
|
+
the omitted-variable error and therefore likely endogenous.
|
|
60
|
+
|
|
61
|
+
The augmented model::
|
|
62
|
+
|
|
63
|
+
Y = beta_0 + sum_j beta_j X_j + sum_{k in suspected} gamma_k P_k + e
|
|
64
|
+
|
|
65
|
+
is estimated by OLS on the latent-variable scores. Inference on the
|
|
66
|
+
copula coefficients is obtained by a non-parametric bootstrap: rows
|
|
67
|
+
are resampled with replacement, the augmented regression is refit,
|
|
68
|
+
and the standard error is the empirical standard deviation of the
|
|
69
|
+
bootstrap distribution. The two-sided p-value uses the normal-
|
|
70
|
+
approximation ``t = gamma / SE`` and ``p = 2 * (1 - Phi(|t|))``, the
|
|
71
|
+
same convention used by :class:`.long_bootstrap.LongBootstrap`.
|
|
72
|
+
|
|
73
|
+
The procedure assumes the suspected predictor is *non-normal*: the
|
|
74
|
+
copula term degenerates under a Gaussian regressor because
|
|
75
|
+
``Phi^{-1}(F_n(X))`` is then approximately identical to ``X`` itself.
|
|
76
|
+
Each tested predictor is screened with the Cramér-von Mises test
|
|
77
|
+
against the empirical-mean / empirical-sd normal distribution and
|
|
78
|
+
the p-value is reported alongside the coefficient. A small CvM
|
|
79
|
+
p-value (e.g. ``< 0.05``) means non-normality is supported and the
|
|
80
|
+
copula approach is admissible (Hult et al. 2018).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
config: c.Config,
|
|
86
|
+
scores: pd.DataFrame,
|
|
87
|
+
endogenous: str,
|
|
88
|
+
suspected: list[str] | None = None,
|
|
89
|
+
n_boot: int = 500,
|
|
90
|
+
seed: int | None = 42,
|
|
91
|
+
):
|
|
92
|
+
path = config.path()
|
|
93
|
+
if endogenous not in path.index:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"endogenous LV {endogenous!r} not found in the structural model"
|
|
96
|
+
)
|
|
97
|
+
predictors = [lv for lv in path.columns if path.loc[endogenous, lv] == 1]
|
|
98
|
+
if not predictors:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"LV {endogenous!r} has no predecessors; nothing to test"
|
|
101
|
+
)
|
|
102
|
+
if suspected is None:
|
|
103
|
+
suspected = list(predictors)
|
|
104
|
+
unknown = [lv for lv in suspected if lv not in predictors]
|
|
105
|
+
if unknown:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"suspected LVs {unknown!r} are not predecessors of {endogenous!r}"
|
|
108
|
+
)
|
|
109
|
+
if n_boot < 50:
|
|
110
|
+
raise ValueError("n_boot must be at least 50")
|
|
111
|
+
|
|
112
|
+
y = scores[endogenous].to_numpy(dtype=float)
|
|
113
|
+
x_full = scores[predictors].to_numpy(dtype=float)
|
|
114
|
+
n = y.shape[0]
|
|
115
|
+
|
|
116
|
+
copula_terms = {lv: _copula_term(scores[lv].to_numpy(dtype=float)) for lv in suspected}
|
|
117
|
+
|
|
118
|
+
normality = {}
|
|
119
|
+
for lv in suspected:
|
|
120
|
+
arr = scores[lv].to_numpy(dtype=float)
|
|
121
|
+
cvm = stats.cramervonmises(
|
|
122
|
+
arr,
|
|
123
|
+
"norm",
|
|
124
|
+
args=(float(arr.mean()), float(arr.std(ddof=1))),
|
|
125
|
+
)
|
|
126
|
+
normality[lv] = float(cvm.pvalue)
|
|
127
|
+
|
|
128
|
+
augment = np.column_stack([copula_terms[lv] for lv in suspected])
|
|
129
|
+
design = np.column_stack([x_full, augment])
|
|
130
|
+
coef_full = _ols(y, design)
|
|
131
|
+
coef_paths = pd.Series(
|
|
132
|
+
coef_full[: len(predictors)], index=predictors, name="estimate"
|
|
133
|
+
)
|
|
134
|
+
coef_copulas = pd.Series(
|
|
135
|
+
coef_full[len(predictors) :], index=suspected, name="estimate"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
rng = np.random.default_rng(seed)
|
|
139
|
+
boot_copulas = np.empty((n_boot, len(suspected)))
|
|
140
|
+
for b in range(n_boot):
|
|
141
|
+
idx = rng.integers(0, n, size=n)
|
|
142
|
+
try:
|
|
143
|
+
coef_b = _ols(y[idx], design[idx])
|
|
144
|
+
except np.linalg.LinAlgError:
|
|
145
|
+
boot_copulas[b, :] = np.nan
|
|
146
|
+
continue
|
|
147
|
+
boot_copulas[b, :] = coef_b[len(predictors) :]
|
|
148
|
+
|
|
149
|
+
valid_mask = ~np.isnan(boot_copulas).any(axis=1)
|
|
150
|
+
boot_valid = boot_copulas[valid_mask]
|
|
151
|
+
if boot_valid.shape[0] < 50:
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
f"only {boot_valid.shape[0]} bootstrap fits succeeded; "
|
|
154
|
+
"the augmented regression may be near-singular"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
se = boot_valid.std(axis=0, ddof=1)
|
|
158
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
159
|
+
t_vals = np.where(se > 0, coef_copulas.values / se, np.nan)
|
|
160
|
+
p_vals = np.where(
|
|
161
|
+
np.isnan(t_vals), np.nan, 2.0 * (1.0 - stats.norm.cdf(np.abs(t_vals)))
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
coef_table = pd.DataFrame(
|
|
165
|
+
{
|
|
166
|
+
"predictor": suspected,
|
|
167
|
+
"gamma": coef_copulas.values,
|
|
168
|
+
"boot_se": se,
|
|
169
|
+
"t": t_vals,
|
|
170
|
+
"p_value": p_vals,
|
|
171
|
+
"cvm_p_nonnormal": [normality[lv] for lv in suspected],
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
self.__endogenous = endogenous
|
|
176
|
+
self.__predictors = predictors
|
|
177
|
+
self.__suspected = list(suspected)
|
|
178
|
+
self.__n_boot = int(boot_valid.shape[0])
|
|
179
|
+
self.__alpha = 0.05
|
|
180
|
+
self.__augmented_paths = coef_paths
|
|
181
|
+
self.__coefficients = coef_table
|
|
182
|
+
|
|
183
|
+
def endogenous(self) -> str:
|
|
184
|
+
"""The endogenous LV under test."""
|
|
185
|
+
return self.__endogenous
|
|
186
|
+
|
|
187
|
+
def predictors(self) -> list[str]:
|
|
188
|
+
"""All structural predecessors of the endogenous LV."""
|
|
189
|
+
return list(self.__predictors)
|
|
190
|
+
|
|
191
|
+
def suspected(self) -> list[str]:
|
|
192
|
+
"""Predictors that received a Gaussian-copula augmentation term."""
|
|
193
|
+
return list(self.__suspected)
|
|
194
|
+
|
|
195
|
+
def coefficients(self) -> pd.DataFrame:
|
|
196
|
+
"""Per-predictor copula diagnostics.
|
|
197
|
+
|
|
198
|
+
Columns: ``predictor``, ``gamma`` (the copula coefficient in the
|
|
199
|
+
augmented regression), ``boot_se`` (bootstrap standard error),
|
|
200
|
+
``t`` and ``p_value`` (two-sided normal-approximation), and
|
|
201
|
+
``cvm_p_nonnormal`` (Cramér-von Mises p-value of the predictor
|
|
202
|
+
against a normal with its sample mean / sample sd; small means
|
|
203
|
+
non-normality is supported and the copula approach is
|
|
204
|
+
admissible).
|
|
205
|
+
"""
|
|
206
|
+
return self.__coefficients
|
|
207
|
+
|
|
208
|
+
def augmented_paths(self) -> pd.Series:
|
|
209
|
+
"""Path coefficients of the endogenous LV's structural equation
|
|
210
|
+
*with* the copula terms in the model. These are the endogeneity-
|
|
211
|
+
corrected estimates that should be compared with the original
|
|
212
|
+
:meth:`Plspm.path_coefficients` to gauge the magnitude of the
|
|
213
|
+
endogeneity bias.
|
|
214
|
+
"""
|
|
215
|
+
return self.__augmented_paths
|
|
216
|
+
|
|
217
|
+
def summary(self) -> pd.DataFrame:
|
|
218
|
+
"""Per-predictor decision summary.
|
|
219
|
+
|
|
220
|
+
Adds a ``decision`` column to :meth:`coefficients`:
|
|
221
|
+
|
|
222
|
+
* ``"endogeneity detected"`` if ``p_value <= alpha`` and the
|
|
223
|
+
Cramér-von Mises non-normality test rejects normality at
|
|
224
|
+
``alpha``;
|
|
225
|
+
* ``"copula not admissible (normal)"`` if the Cramér-von Mises
|
|
226
|
+
test fails to reject normality at ``alpha`` — the test
|
|
227
|
+
cannot distinguish endogeneity from a Gaussian regressor;
|
|
228
|
+
* ``"no endogeneity detected"`` otherwise.
|
|
229
|
+
"""
|
|
230
|
+
alpha = self.__alpha
|
|
231
|
+
out = self.__coefficients.copy()
|
|
232
|
+
decisions: list[str] = []
|
|
233
|
+
for _, row in out.iterrows():
|
|
234
|
+
if row["cvm_p_nonnormal"] > alpha:
|
|
235
|
+
decisions.append("copula not admissible (normal)")
|
|
236
|
+
elif np.isnan(row["p_value"]):
|
|
237
|
+
decisions.append("inconclusive")
|
|
238
|
+
elif row["p_value"] <= alpha:
|
|
239
|
+
decisions.append("endogeneity detected")
|
|
240
|
+
else:
|
|
241
|
+
decisions.append("no endogeneity detected")
|
|
242
|
+
out["decision"] = decisions
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
def n_boot(self) -> int:
|
|
246
|
+
"""Number of successful bootstrap iterations used for SE / p."""
|
|
247
|
+
return self.__n_boot
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2026 Johannes Jacob / OpenPLS
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
from itertools import combinations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
import openpls.config as c
|
|
24
|
+
from openpls.mode import Mode
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _canonical_tetrad(cov: np.ndarray, i: int, j: int, k: int, l: int) -> float:
|
|
28
|
+
"""Canonical tetrad ``s_ij * s_kl - s_ik * s_jl`` for the index quadruple.
|
|
29
|
+
|
|
30
|
+
One canonical tetrad is selected per indicator 4-tuple, giving
|
|
31
|
+
``C(p, 4)`` non-redundant tetrads per block. For a one-factor (purely
|
|
32
|
+
reflective) measurement model all such tetrads have expectation zero.
|
|
33
|
+
"""
|
|
34
|
+
return float(cov[i, j] * cov[k, l] - cov[i, k] * cov[j, l])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CTAPLS:
|
|
38
|
+
"""Confirmatory Tetrad Analysis for PLS (Gudergan, Ringle, Wende & Will 2008).
|
|
39
|
+
|
|
40
|
+
Diagnostic for the *outer model*: tests whether reflective (Mode A)
|
|
41
|
+
specification of each block with four or more indicators is consistent
|
|
42
|
+
with the data. The procedure relies on Bollen and Ting's (1993)
|
|
43
|
+
vanishing-tetrad theorem — for a one-factor block every model-implied
|
|
44
|
+
tetrad of the indicator covariance matrix has expectation zero. Sample
|
|
45
|
+
tetrads are bootstrapped to obtain two-sided p-values for the null
|
|
46
|
+
``H0: tau = 0``; the Holm step-down correction is applied within each
|
|
47
|
+
block to control the family-wise error rate at ``alpha``. If any tetrad
|
|
48
|
+
in a block is rejected, the reflective specification is not supported
|
|
49
|
+
and a formative (Mode B) specification should be considered.
|
|
50
|
+
|
|
51
|
+
Only Mode A blocks with at least four indicators are tested. Mode B
|
|
52
|
+
blocks and reflective blocks with fewer than four indicators are
|
|
53
|
+
omitted (tetrads are undefined / vacuously satisfied).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
config: c.Config,
|
|
59
|
+
data: pd.DataFrame,
|
|
60
|
+
n_boot: int = 500,
|
|
61
|
+
alpha: float = 0.05,
|
|
62
|
+
seed: int | None = 42,
|
|
63
|
+
):
|
|
64
|
+
if n_boot < 50:
|
|
65
|
+
raise ValueError(f"n_boot must be >= 50, got {n_boot}")
|
|
66
|
+
if not 0.0 < alpha < 1.0:
|
|
67
|
+
raise ValueError(f"alpha must be in (0, 1), got {alpha}")
|
|
68
|
+
self.__alpha = alpha
|
|
69
|
+
self.__n_boot = n_boot
|
|
70
|
+
self.__tetrads, self.__summary = self.__compute(
|
|
71
|
+
config, data, n_boot, alpha, seed
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def __compute(
|
|
76
|
+
config: c.Config,
|
|
77
|
+
data: pd.DataFrame,
|
|
78
|
+
n_boot: int,
|
|
79
|
+
alpha: float,
|
|
80
|
+
seed: int | None,
|
|
81
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
82
|
+
rng = np.random.default_rng(seed)
|
|
83
|
+
tetrad_rows: list[dict] = []
|
|
84
|
+
summary_rows: list[dict] = []
|
|
85
|
+
for lv in config.path().columns:
|
|
86
|
+
if config.mode(lv) != Mode.A:
|
|
87
|
+
continue
|
|
88
|
+
inds = [mv for mv in config.mvs(lv) if mv in data.columns]
|
|
89
|
+
if len(inds) < 4:
|
|
90
|
+
continue
|
|
91
|
+
block = data[inds].dropna()
|
|
92
|
+
if block.shape[0] < 10:
|
|
93
|
+
continue
|
|
94
|
+
block_np = block.to_numpy(dtype=float)
|
|
95
|
+
n_obs, p = block_np.shape
|
|
96
|
+
quads = list(combinations(range(p), 4))
|
|
97
|
+
m = len(quads)
|
|
98
|
+
sample_cov = np.cov(block_np, rowvar=False, ddof=1)
|
|
99
|
+
observed = np.array(
|
|
100
|
+
[_canonical_tetrad(sample_cov, *quad) for quad in quads]
|
|
101
|
+
)
|
|
102
|
+
boot_values = np.empty((n_boot, m), dtype=float)
|
|
103
|
+
for b in range(n_boot):
|
|
104
|
+
idx = rng.integers(n_obs, size=n_obs)
|
|
105
|
+
cov_b = np.cov(block_np[idx, :], rowvar=False, ddof=1)
|
|
106
|
+
for t, quad in enumerate(quads):
|
|
107
|
+
boot_values[b, t] = _canonical_tetrad(cov_b, *quad)
|
|
108
|
+
ses = boot_values.std(axis=0, ddof=1)
|
|
109
|
+
# Two-sided percentile p-value under H0: tau = 0. Center the
|
|
110
|
+
# bootstrap distribution on zero before comparing magnitudes.
|
|
111
|
+
centered = boot_values - boot_values.mean(axis=0, keepdims=True)
|
|
112
|
+
p_values = np.mean(
|
|
113
|
+
np.abs(centered) >= np.abs(observed), axis=0
|
|
114
|
+
)
|
|
115
|
+
# Floor at 1/n_boot so log scales and Holm comparisons stay sane.
|
|
116
|
+
p_values = np.maximum(p_values, 1.0 / n_boot)
|
|
117
|
+
decisions = CTAPLS.__holm(p_values, alpha)
|
|
118
|
+
n_rejected = int(sum(d == "reject" for d in decisions))
|
|
119
|
+
block_decision = (
|
|
120
|
+
"reflective rejected"
|
|
121
|
+
if n_rejected > 0
|
|
122
|
+
else "reflective supported"
|
|
123
|
+
)
|
|
124
|
+
for t, quad in enumerate(quads):
|
|
125
|
+
i, j, k, l = quad
|
|
126
|
+
tetrad_rows.append(
|
|
127
|
+
{
|
|
128
|
+
"lv": lv,
|
|
129
|
+
"indicators": f"{inds[i]},{inds[j]},{inds[k]},{inds[l]}",
|
|
130
|
+
"tetrad": float(observed[t]),
|
|
131
|
+
"boot_se": float(ses[t]),
|
|
132
|
+
"p_value": float(p_values[t]),
|
|
133
|
+
"holm_decision": decisions[t],
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
summary_rows.append(
|
|
137
|
+
{
|
|
138
|
+
"lv": lv,
|
|
139
|
+
"n_indicators": len(inds),
|
|
140
|
+
"n_tetrads": m,
|
|
141
|
+
"n_rejected": n_rejected,
|
|
142
|
+
"decision": block_decision,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
tetrads_df = pd.DataFrame(
|
|
146
|
+
tetrad_rows,
|
|
147
|
+
columns=[
|
|
148
|
+
"lv",
|
|
149
|
+
"indicators",
|
|
150
|
+
"tetrad",
|
|
151
|
+
"boot_se",
|
|
152
|
+
"p_value",
|
|
153
|
+
"holm_decision",
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
summary_df = pd.DataFrame(
|
|
157
|
+
summary_rows,
|
|
158
|
+
columns=[
|
|
159
|
+
"lv",
|
|
160
|
+
"n_indicators",
|
|
161
|
+
"n_tetrads",
|
|
162
|
+
"n_rejected",
|
|
163
|
+
"decision",
|
|
164
|
+
],
|
|
165
|
+
)
|
|
166
|
+
return tetrads_df, summary_df
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def __holm(p_values: np.ndarray, alpha: float) -> list[str]:
|
|
170
|
+
"""Holm step-down rejection given a vector of p-values."""
|
|
171
|
+
m = len(p_values)
|
|
172
|
+
order = np.argsort(p_values)
|
|
173
|
+
decisions = ["fail to reject"] * m
|
|
174
|
+
for rank, t in enumerate(order):
|
|
175
|
+
if p_values[t] < alpha / (m - rank):
|
|
176
|
+
decisions[t] = "reject"
|
|
177
|
+
else:
|
|
178
|
+
break
|
|
179
|
+
return decisions
|
|
180
|
+
|
|
181
|
+
def tetrads(self) -> pd.DataFrame:
|
|
182
|
+
"""Per-tetrad results.
|
|
183
|
+
|
|
184
|
+
Long format with one row per tested tetrad and columns ``lv``,
|
|
185
|
+
``indicators`` (the four indicator names that form the tetrad,
|
|
186
|
+
comma-separated), ``tetrad`` (observed sample value), ``boot_se``,
|
|
187
|
+
``p_value``, and ``holm_decision`` (``"reject"`` /
|
|
188
|
+
``"fail to reject"``, after the within-block Holm correction at the
|
|
189
|
+
configured ``alpha``).
|
|
190
|
+
"""
|
|
191
|
+
return self.__tetrads
|
|
192
|
+
|
|
193
|
+
def summary(self) -> pd.DataFrame:
|
|
194
|
+
"""Per-block CTA-PLS verdict.
|
|
195
|
+
|
|
196
|
+
One row per tested Mode A block (≥ 4 indicators) with columns
|
|
197
|
+
``lv``, ``n_indicators``, ``n_tetrads``, ``n_rejected``, and
|
|
198
|
+
``decision`` (``"reflective supported"`` when no tetrad rejects
|
|
199
|
+
after Holm correction; ``"reflective rejected"`` otherwise).
|
|
200
|
+
"""
|
|
201
|
+
return self.__summary
|
|
202
|
+
|
|
203
|
+
def alpha(self) -> float:
|
|
204
|
+
"""Family-wise significance level used for the Holm correction."""
|
|
205
|
+
return self.__alpha
|
|
206
|
+
|
|
207
|
+
def n_boot(self) -> int:
|
|
208
|
+
"""Number of bootstrap resamples used to estimate tetrad SEs."""
|
|
209
|
+
return self.__n_boot
|