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.
Files changed (69) hide show
  1. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/PKG-INFO +29 -8
  2. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/README.md +27 -6
  3. openpls_engine-1.2.0/openpls/copula.py +247 -0
  4. openpls_engine-1.2.0/openpls/cta.py +209 -0
  5. openpls_engine-1.2.0/openpls/htmt2.py +142 -0
  6. openpls_engine-1.2.0/openpls/plsc.py +205 -0
  7. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/plspm.py +139 -0
  8. openpls_engine-1.2.0/openpls/vif.py +134 -0
  9. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/PKG-INFO +29 -8
  10. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/SOURCES.txt +11 -1
  11. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/pyproject.toml +2 -2
  12. openpls_engine-1.2.0/tests/test_copula.py +220 -0
  13. openpls_engine-1.2.0/tests/test_cta.py +197 -0
  14. openpls_engine-1.2.0/tests/test_htmt2.py +164 -0
  15. openpls_engine-1.2.0/tests/test_plsc.py +218 -0
  16. openpls_engine-1.2.0/tests/test_vif.py +112 -0
  17. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/LICENSE +0 -0
  18. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/__init__.py +0 -0
  19. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/bootstrap.py +0 -0
  20. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/config.py +0 -0
  21. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/estimator.py +0 -0
  22. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/fimix.py +0 -0
  23. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/fit.py +0 -0
  24. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/htmt.py +0 -0
  25. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/inner_model.py +0 -0
  26. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/inner_summary.py +0 -0
  27. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/ipma.py +0 -0
  28. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/long_bootstrap.py +0 -0
  29. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/mga.py +0 -0
  30. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/mode.py +0 -0
  31. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/moderation.py +0 -0
  32. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/outer_model.py +0 -0
  33. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/predict.py +0 -0
  34. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/q_squared.py +0 -0
  35. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/scale.py +0 -0
  36. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/scheme.py +0 -0
  37. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/unidimensionality.py +0 -0
  38. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/util.py +0 -0
  39. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls/weights.py +0 -0
  40. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/dependency_links.txt +0 -0
  41. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/requires.txt +0 -0
  42. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/openpls_engine.egg-info/top_level.txt +0 -0
  43. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/setup.cfg +0 -0
  44. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_config.py +0 -0
  45. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_estimator.py +0 -0
  46. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_fimix.py +0 -0
  47. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_fit.py +0 -0
  48. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_htmt.py +0 -0
  49. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_inner_summary_metrics.py +0 -0
  50. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_ipma.py +0 -0
  51. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_long_bootstrap.py +0 -0
  52. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_lv_mv_name_collision.py +0 -0
  53. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_mga.py +0 -0
  54. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_missing_strategy.py +0 -0
  55. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_moderation.py +0 -0
  56. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_path_recovery_synthetic.py +0 -0
  57. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_predict.py +0 -0
  58. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_q_squared.py +0 -0
  59. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_redundancy_analysis_mode_b.py +0 -0
  60. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_regression_bootstrap.py +0 -0
  61. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_regression_metric.py +0 -0
  62. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_regression_nonmetric.py +0 -0
  63. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_regression_seminr.py +0 -0
  64. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_scheme_equivalence_two_lv.py +0 -0
  65. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_scheme_newton.py +0 -0
  66. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_scheme_pca.py +0 -0
  67. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_sign_convention.py +0 -0
  68. {openpls_engine-1.0.2 → openpls_engine-1.2.0}/tests/test_unidimensionality_fallback.py +0 -0
  69. {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.2
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
  [![PyPI version](https://img.shields.io/pypi/v/openpls-engine.svg)](https://pypi.org/project/openpls-engine/)
36
36
  [![Python versions](https://img.shields.io/pypi/pyversions/openpls-engine.svg)](https://pypi.org/project/openpls-engine/)
37
37
  [![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPLv3+-blue.svg)](LICENSE)
38
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20509385.svg)](https://doi.org/10.5281/zenodo.20509385)
39
+ [![Validation DOI](https://img.shields.io/badge/validation%20data-10.5281%2Fzenodo.20511533-blue)](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, SRMR,
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
- multi-group analysis, and a progress-streaming long bootstrap. It also ships
48
- **advanced** analyses that go beyond what `plspm-python` covers: PLSpredict
49
- out-of-sample validation, IPMA (Importance-Performance Map Analysis),
50
- two-stage moderation, FIMIX-PLS finite-mixture segmentation, and a
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.2
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
  [![PyPI version](https://img.shields.io/pypi/v/openpls-engine.svg)](https://pypi.org/project/openpls-engine/)
5
5
  [![Python versions](https://img.shields.io/pypi/pyversions/openpls-engine.svg)](https://pypi.org/project/openpls-engine/)
6
6
  [![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPLv3+-blue.svg)](LICENSE)
7
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20509385.svg)](https://doi.org/10.5281/zenodo.20509385)
8
+ [![Validation DOI](https://img.shields.io/badge/validation%20data-10.5281%2Fzenodo.20511533-blue)](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, SRMR,
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
- multi-group analysis, and a progress-streaming long bootstrap. It also ships
17
- **advanced** analyses that go beyond what `plspm-python` covers: PLSpredict
18
- out-of-sample validation, IPMA (Importance-Performance Map Analysis),
19
- two-stage moderation, FIMIX-PLS finite-mixture segmentation, and a
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.2
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