openpls-engine 1.2.0__tar.gz → 1.3.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.2.0/openpls_engine.egg-info → openpls_engine-1.3.0}/PKG-INFO +6 -4
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/README.md +4 -2
- openpls_engine-1.3.0/openpls/higher_order.py +291 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/plspm.py +59 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0/openpls_engine.egg-info}/PKG-INFO +6 -4
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls_engine.egg-info/SOURCES.txt +2 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/pyproject.toml +2 -2
- openpls_engine-1.3.0/tests/test_higher_order.py +355 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/LICENSE +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/__init__.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/bootstrap.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/config.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/copula.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/cta.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/estimator.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/fimix.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/fit.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/htmt.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/htmt2.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/inner_model.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/inner_summary.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/ipma.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/long_bootstrap.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/mga.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/mode.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/moderation.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/outer_model.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/plsc.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/predict.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/q_squared.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/scale.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/scheme.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/unidimensionality.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/util.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/vif.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/weights.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls_engine.egg-info/dependency_links.txt +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls_engine.egg-info/requires.txt +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls_engine.egg-info/top_level.txt +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/setup.cfg +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_config.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_copula.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_cta.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_estimator.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_fimix.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_fit.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_htmt.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_htmt2.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_inner_summary_metrics.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_ipma.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_long_bootstrap.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_lv_mv_name_collision.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_mga.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_missing_strategy.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_moderation.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_path_recovery_synthetic.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_plsc.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_predict.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_q_squared.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_redundancy_analysis_mode_b.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_regression_bootstrap.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_regression_metric.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_regression_nonmetric.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_regression_seminr.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_scheme_equivalence_two_lv.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_scheme_newton.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_scheme_pca.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_sign_convention.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_unidimensionality_fallback.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_util.py +0 -0
- {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_vif.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openpls-engine
|
|
3
|
-
Version: 1.
|
|
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.
|
|
3
|
+
Version: 1.3.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, disjoint two-stage HOC) 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
|
|
@@ -56,6 +56,8 @@ attenuation correction in reflective models, PLSpredict out-of-sample
|
|
|
56
56
|
validation, IPMA (Importance-Performance Map Analysis),
|
|
57
57
|
two-stage moderation, FIMIX-PLS finite-mixture segmentation, the
|
|
58
58
|
Gaussian-copula endogeneity test (Park & Gupta 2012; Hult et al. 2018),
|
|
59
|
+
disjoint two-stage higher-order constructs (Sarstedt et al. 2019; Hair
|
|
60
|
+
et al. 2022),
|
|
59
61
|
and a
|
|
60
62
|
**quasi-Newton (BFGS) inner-weighting scheme** and a **Lohmöller PCA
|
|
61
63
|
scheme**, two new alternatives to the classical centroid, factorial, and
|
|
@@ -78,7 +80,7 @@ Hosted docs at [**openpls.app/engine**](https://openpls.app/engine):
|
|
|
78
80
|
- [Installation](https://openpls.app/engine/installation/) - pip and source installs.
|
|
79
81
|
- [Quickstart](https://openpls.app/engine/quickstart/) - end-to-end fit on the satisfaction dataset.
|
|
80
82
|
- [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).
|
|
83
|
+
- [API reference](https://openpls.app/engine/api/) - the full public surface (Plspm, Config, VIF, CTAPLS, PLSc, GaussianCopula, HigherOrder, IPMA, PLSpredict, Moderation, FIMIX, MGA, LongBootstrap).
|
|
82
84
|
- [Examples](https://openpls.app/engine/examples/) - runnable snippets for each advanced analysis.
|
|
83
85
|
- [Changelog](https://openpls.app/engine/changelog/) - version history with per-feature notes.
|
|
84
86
|
|
|
@@ -102,7 +104,7 @@ pip install openpls-engine
|
|
|
102
104
|
Or pin a specific version for reproducible analyses:
|
|
103
105
|
|
|
104
106
|
```sh
|
|
105
|
-
pip install openpls-engine==1.
|
|
107
|
+
pip install openpls-engine==1.3.0
|
|
106
108
|
```
|
|
107
109
|
|
|
108
110
|
To work from source instead:
|
|
@@ -25,6 +25,8 @@ attenuation correction in reflective models, PLSpredict out-of-sample
|
|
|
25
25
|
validation, IPMA (Importance-Performance Map Analysis),
|
|
26
26
|
two-stage moderation, FIMIX-PLS finite-mixture segmentation, the
|
|
27
27
|
Gaussian-copula endogeneity test (Park & Gupta 2012; Hult et al. 2018),
|
|
28
|
+
disjoint two-stage higher-order constructs (Sarstedt et al. 2019; Hair
|
|
29
|
+
et al. 2022),
|
|
28
30
|
and a
|
|
29
31
|
**quasi-Newton (BFGS) inner-weighting scheme** and a **Lohmöller PCA
|
|
30
32
|
scheme**, two new alternatives to the classical centroid, factorial, and
|
|
@@ -47,7 +49,7 @@ Hosted docs at [**openpls.app/engine**](https://openpls.app/engine):
|
|
|
47
49
|
- [Installation](https://openpls.app/engine/installation/) - pip and source installs.
|
|
48
50
|
- [Quickstart](https://openpls.app/engine/quickstart/) - end-to-end fit on the satisfaction dataset.
|
|
49
51
|
- [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).
|
|
52
|
+
- [API reference](https://openpls.app/engine/api/) - the full public surface (Plspm, Config, VIF, CTAPLS, PLSc, GaussianCopula, HigherOrder, IPMA, PLSpredict, Moderation, FIMIX, MGA, LongBootstrap).
|
|
51
53
|
- [Examples](https://openpls.app/engine/examples/) - runnable snippets for each advanced analysis.
|
|
52
54
|
- [Changelog](https://openpls.app/engine/changelog/) - version history with per-feature notes.
|
|
53
55
|
|
|
@@ -71,7 +73,7 @@ pip install openpls-engine
|
|
|
71
73
|
Or pin a specific version for reproducible analyses:
|
|
72
74
|
|
|
73
75
|
```sh
|
|
74
|
-
pip install openpls-engine==1.
|
|
76
|
+
pip install openpls-engine==1.3.0
|
|
75
77
|
```
|
|
76
78
|
|
|
77
79
|
To work from source instead:
|
|
@@ -0,0 +1,291 @@
|
|
|
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
|
+
"""Disjoint two-stage higher-order construct (HOC) workflow for PLS-SEM.
|
|
19
|
+
|
|
20
|
+
Implements the disjoint two-stage approach recommended by
|
|
21
|
+
Sarstedt, Hair, Cheah, Becker & Ringle (2019) and Hair, Hult, Ringle &
|
|
22
|
+
Sarstedt (2022, A primer on PLS-SEM, 3rd ed., Chapter 8):
|
|
23
|
+
|
|
24
|
+
1. **Stage 1**: Fit the lower-order model in which the constituent
|
|
25
|
+
first-order constructs appear as siblings (no second-order LV).
|
|
26
|
+
2. **Stage 2**: Use the standardized first-order LV scores from stage 1
|
|
27
|
+
as the **indicators** of the second-order construct, and refit a
|
|
28
|
+
model where the HOC replaces its first-order constructs in the
|
|
29
|
+
structural part of the model.
|
|
30
|
+
|
|
31
|
+
This is statistically cleaner than the legacy ``Config.add_higher_order``
|
|
32
|
+
flow (repeated-indicators / embedded two-stage), because in the disjoint
|
|
33
|
+
version the first-order constructs are not simultaneously their own
|
|
34
|
+
measurement and the HOC's predictors. The four canonical HOC types are
|
|
35
|
+
covered via the ``mode`` argument and the modes already set on the
|
|
36
|
+
first-order LVs:
|
|
37
|
+
|
|
38
|
+
* **Reflective-Reflective (Type I)**: first-order Mode A, HOC Mode A.
|
|
39
|
+
* **Reflective-Formative (Type II)**: first-order Mode A, HOC Mode B.
|
|
40
|
+
* **Formative-Reflective (Type III)**: first-order Mode B, HOC Mode A.
|
|
41
|
+
* **Formative-Formative (Type IV)**: first-order Mode B, HOC Mode B.
|
|
42
|
+
|
|
43
|
+
References
|
|
44
|
+
----------
|
|
45
|
+
- Sarstedt, M., Hair, J. F., Cheah, J.-H., Becker, J.-M., & Ringle, C. M.
|
|
46
|
+
(2019). How to specify, estimate, and validate higher-order constructs
|
|
47
|
+
in PLS-SEM. Australasian Marketing Journal, 27(3), 197-211.
|
|
48
|
+
- Hair, J. F., Hult, G. T. M., Ringle, C. M., & Sarstedt, M. (2022).
|
|
49
|
+
A primer on partial least squares structural equation modeling
|
|
50
|
+
(PLS-SEM), 3rd ed., Chapter 8.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import pandas as pd
|
|
56
|
+
|
|
57
|
+
from openpls.config import MV, Config, Structure
|
|
58
|
+
from openpls.mode import Mode
|
|
59
|
+
from openpls.scheme import Scheme
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_stage2_config(
|
|
63
|
+
base_config: Config,
|
|
64
|
+
hoc_name: str,
|
|
65
|
+
first_order: list[str],
|
|
66
|
+
hoc_mode: Mode,
|
|
67
|
+
structure: Structure,
|
|
68
|
+
score_columns: dict[str, str],
|
|
69
|
+
) -> Config:
|
|
70
|
+
"""Build the stage-2 Config: HOC has the first-order LV scores as
|
|
71
|
+
indicators; every other LV reuses its original indicators."""
|
|
72
|
+
stage2_path = structure.path()
|
|
73
|
+
if hoc_name not in stage2_path.index:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"HOC {hoc_name!r} does not appear in the stage-2 structure"
|
|
76
|
+
)
|
|
77
|
+
for lo in first_order:
|
|
78
|
+
if lo in stage2_path.index:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"first-order LV {lo!r} must NOT appear in the stage-2 "
|
|
81
|
+
f"structure (it has been rolled into {hoc_name!r})"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
stage2 = Config(stage2_path, scaled=base_config.scaled())
|
|
85
|
+
for lv in stage2_path.index:
|
|
86
|
+
if lv == hoc_name:
|
|
87
|
+
stage2.add_lv(
|
|
88
|
+
hoc_name,
|
|
89
|
+
hoc_mode,
|
|
90
|
+
*[MV(score_columns[lo]) for lo in first_order],
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
if lv not in base_config.path().index:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"stage-2 LV {lv!r} is not in the base config; HigherOrder "
|
|
96
|
+
"cannot synthesize indicators for new constructs"
|
|
97
|
+
)
|
|
98
|
+
stage2.add_lv(
|
|
99
|
+
lv,
|
|
100
|
+
base_config.mode(lv),
|
|
101
|
+
*[MV(name) for name in base_config.mvs(lv)],
|
|
102
|
+
)
|
|
103
|
+
return stage2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class HigherOrder:
|
|
107
|
+
"""Disjoint two-stage higher-order construct (Hair et al. 2022).
|
|
108
|
+
|
|
109
|
+
The base ``Plspm`` fit serves as **stage 1**. The constructor extracts
|
|
110
|
+
its scores for the ``first_order`` LVs, appends them as columns to
|
|
111
|
+
the data, and fits a **stage-2** ``Plspm`` in which:
|
|
112
|
+
|
|
113
|
+
* the HOC has the first-order LV scores as its manifest variables
|
|
114
|
+
(mode = ``mode``), and
|
|
115
|
+
* every other LV from the base model that still appears in the
|
|
116
|
+
stage-2 ``structure`` keeps its original indicators.
|
|
117
|
+
|
|
118
|
+
The four canonical HOC types are obtained by combining the first-
|
|
119
|
+
order LV modes (set on ``base_config``) with the HOC ``mode``:
|
|
120
|
+
|
|
121
|
+
* Type I (R-R): first-order A, ``mode=Mode.A``;
|
|
122
|
+
* Type II (R-F): first-order A, ``mode=Mode.B``;
|
|
123
|
+
* Type III(F-R): first-order B, ``mode=Mode.A``;
|
|
124
|
+
* Type IV (F-F): first-order B, ``mode=Mode.B``.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
base
|
|
129
|
+
A fitted :class:`.plspm.Plspm` whose LV scores will become the
|
|
130
|
+
HOC's indicators.
|
|
131
|
+
name
|
|
132
|
+
Name of the new second-order construct. Must not collide with an
|
|
133
|
+
existing LV or with any indicator column in the data.
|
|
134
|
+
first_order
|
|
135
|
+
Names of the first-order LVs to roll up. All must exist in the
|
|
136
|
+
base model and be exposed in ``base.scores()``.
|
|
137
|
+
mode
|
|
138
|
+
Mode of the HOC's measurement w.r.t. its first-order indicators.
|
|
139
|
+
structure
|
|
140
|
+
:class:`.config.Structure` of the stage-2 path model. Must
|
|
141
|
+
contain ``name`` and may contain any other LV that was in the
|
|
142
|
+
base model except the ones listed in ``first_order``.
|
|
143
|
+
scheme, iterations, tolerance, missing_strategy
|
|
144
|
+
Passed through to the stage-2 :class:`.plspm.Plspm` fit.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
base,
|
|
150
|
+
name: str,
|
|
151
|
+
first_order: list[str],
|
|
152
|
+
mode: Mode,
|
|
153
|
+
structure: Structure,
|
|
154
|
+
scheme: Scheme = Scheme.CENTROID,
|
|
155
|
+
iterations: int = 100,
|
|
156
|
+
tolerance: float = 1e-6,
|
|
157
|
+
missing_strategy: str = "casewise",
|
|
158
|
+
):
|
|
159
|
+
from openpls.plspm import Plspm # avoid circular import
|
|
160
|
+
|
|
161
|
+
if not isinstance(base, Plspm):
|
|
162
|
+
raise TypeError("base must be a fitted Plspm instance")
|
|
163
|
+
if not isinstance(structure, Structure):
|
|
164
|
+
raise TypeError("structure must be a Structure instance")
|
|
165
|
+
if len(first_order) < 2:
|
|
166
|
+
raise ValueError("a HOC must roll up at least two first-order LVs")
|
|
167
|
+
if len(set(first_order)) != len(first_order):
|
|
168
|
+
raise ValueError("first_order contains duplicate LV names")
|
|
169
|
+
|
|
170
|
+
base_config = base.config()
|
|
171
|
+
base_path = base_config.path()
|
|
172
|
+
for lo in first_order:
|
|
173
|
+
if lo not in base_path.index:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"first-order LV {lo!r} is not in the base model"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
scores = base.scores()
|
|
179
|
+
missing_scores = [lo for lo in first_order if lo not in scores.columns]
|
|
180
|
+
if missing_scores:
|
|
181
|
+
raise RuntimeError(
|
|
182
|
+
f"base fit did not produce scores for {missing_scores!r}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if name in base_path.index:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"HOC name {name!r} clashes with an existing LV in the base model"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
data = base.data()
|
|
191
|
+
score_columns: dict[str, str] = {}
|
|
192
|
+
for lo in first_order:
|
|
193
|
+
col = f"{name}__{lo}"
|
|
194
|
+
if col in data.columns:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"derived indicator column {col!r} already exists in the "
|
|
197
|
+
"original data; choose a different HOC name"
|
|
198
|
+
)
|
|
199
|
+
score_columns[lo] = col
|
|
200
|
+
|
|
201
|
+
data2 = data.copy()
|
|
202
|
+
for lo, col in score_columns.items():
|
|
203
|
+
data2[col] = scores[lo].reindex(data2.index)
|
|
204
|
+
|
|
205
|
+
stage2_config = _build_stage2_config(
|
|
206
|
+
base_config=base_config,
|
|
207
|
+
hoc_name=name,
|
|
208
|
+
first_order=first_order,
|
|
209
|
+
hoc_mode=mode,
|
|
210
|
+
structure=structure,
|
|
211
|
+
score_columns=score_columns,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
refit = Plspm(
|
|
215
|
+
data2,
|
|
216
|
+
stage2_config,
|
|
217
|
+
scheme,
|
|
218
|
+
iterations=iterations,
|
|
219
|
+
tolerance=tolerance,
|
|
220
|
+
missing_strategy=missing_strategy,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
self.__base = base
|
|
224
|
+
self.__refit = refit
|
|
225
|
+
self.__name = name
|
|
226
|
+
self.__first_order = list(first_order)
|
|
227
|
+
self.__hoc_mode = mode
|
|
228
|
+
self.__score_columns = score_columns
|
|
229
|
+
|
|
230
|
+
def name(self) -> str:
|
|
231
|
+
"""Name of the higher-order construct."""
|
|
232
|
+
return self.__name
|
|
233
|
+
|
|
234
|
+
def first_order(self) -> list[str]:
|
|
235
|
+
"""The first-order LVs rolled into the HOC."""
|
|
236
|
+
return list(self.__first_order)
|
|
237
|
+
|
|
238
|
+
def hoc_mode(self) -> Mode:
|
|
239
|
+
"""Measurement mode of the HOC w.r.t. its first-order indicators."""
|
|
240
|
+
return self.__hoc_mode
|
|
241
|
+
|
|
242
|
+
def base(self):
|
|
243
|
+
"""The stage-1 ``Plspm`` fit (first-order siblings)."""
|
|
244
|
+
return self.__base
|
|
245
|
+
|
|
246
|
+
def refit(self):
|
|
247
|
+
"""The stage-2 ``Plspm`` fit (HOC in place of its first-order LVs)."""
|
|
248
|
+
return self.__refit
|
|
249
|
+
|
|
250
|
+
def indicator_columns(self) -> dict[str, str]:
|
|
251
|
+
"""Mapping ``first_order_lv -> indicator column name`` used to
|
|
252
|
+
carry the stage-1 scores into the stage-2 data."""
|
|
253
|
+
return dict(self.__score_columns)
|
|
254
|
+
|
|
255
|
+
def loadings(self) -> pd.Series:
|
|
256
|
+
"""Outer loadings (Mode A) or outer weights (Mode B) of the HOC
|
|
257
|
+
on its first-order indicators, indexed by first-order LV name."""
|
|
258
|
+
outer = self.__refit.outer_model()
|
|
259
|
+
col = "loading" if self.__hoc_mode == Mode.A else "weight"
|
|
260
|
+
out = pd.Series(index=self.__first_order, dtype=float, name=col)
|
|
261
|
+
for lo, ind in self.__score_columns.items():
|
|
262
|
+
out.loc[lo] = float(outer.loc[ind, col])
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
def path_coefficients(self) -> pd.DataFrame:
|
|
266
|
+
"""Stage-2 structural path coefficients (HOC included)."""
|
|
267
|
+
return self.__refit.path_coefficients()
|
|
268
|
+
|
|
269
|
+
def r_squared(self) -> pd.Series:
|
|
270
|
+
"""Stage-2 R² per endogenous LV (HOC included if endogenous)."""
|
|
271
|
+
summary = self.__refit.inner_summary()
|
|
272
|
+
return summary["r_squared"]
|
|
273
|
+
|
|
274
|
+
def summary(self) -> pd.DataFrame:
|
|
275
|
+
"""Per-first-order-LV measurement summary of the HOC: loading or
|
|
276
|
+
weight (depending on ``hoc_mode``), and the stage-1 R² of that
|
|
277
|
+
first-order LV (NaN for exogenous first-order LVs)."""
|
|
278
|
+
base_r2 = self.__base.inner_summary()["r_squared"]
|
|
279
|
+
col = "loading" if self.__hoc_mode == Mode.A else "weight"
|
|
280
|
+
rows = []
|
|
281
|
+
for lo in self.__first_order:
|
|
282
|
+
ind = self.__score_columns[lo]
|
|
283
|
+
outer = self.__refit.outer_model().loc[ind]
|
|
284
|
+
rows.append(
|
|
285
|
+
{
|
|
286
|
+
"first_order": lo,
|
|
287
|
+
col: float(outer[col]),
|
|
288
|
+
"stage1_r_squared": float(base_r2.get(lo, float("nan"))),
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
return pd.DataFrame(rows)
|
|
@@ -29,6 +29,7 @@ from openpls.cta import CTAPLS
|
|
|
29
29
|
from openpls.estimator import Estimator
|
|
30
30
|
from openpls.fimix import FIMIX
|
|
31
31
|
from openpls.fit import ModelFit
|
|
32
|
+
from openpls.higher_order import HigherOrder
|
|
32
33
|
from openpls.htmt import HTMT
|
|
33
34
|
from openpls.htmt2 import HTMT2
|
|
34
35
|
from openpls.ipma import IPMA
|
|
@@ -142,6 +143,15 @@ class Plspm:
|
|
|
142
143
|
"""
|
|
143
144
|
return self.__scores
|
|
144
145
|
|
|
146
|
+
def data(self) -> pd.DataFrame:
|
|
147
|
+
"""The dataset actually used in the fit (after the configured
|
|
148
|
+
missing-value strategy has been applied)."""
|
|
149
|
+
return self.__data
|
|
150
|
+
|
|
151
|
+
def config(self) -> c.Config:
|
|
152
|
+
"""The :class:`.config.Config` used in the fit."""
|
|
153
|
+
return self.__config
|
|
154
|
+
|
|
145
155
|
def outer_model(self) -> pd.DataFrame:
|
|
146
156
|
"""Gets the outer model
|
|
147
157
|
|
|
@@ -473,6 +483,55 @@ class Plspm:
|
|
|
473
483
|
seed=seed,
|
|
474
484
|
)
|
|
475
485
|
|
|
486
|
+
def higher_order(
|
|
487
|
+
self,
|
|
488
|
+
name: str,
|
|
489
|
+
first_order: list[str],
|
|
490
|
+
mode,
|
|
491
|
+
structure,
|
|
492
|
+
iterations: int = 100,
|
|
493
|
+
tolerance: float = 1e-6,
|
|
494
|
+
missing_strategy: str = "casewise",
|
|
495
|
+
) -> HigherOrder:
|
|
496
|
+
"""Disjoint two-stage higher-order construct
|
|
497
|
+
(Sarstedt et al. 2019; Hair et al. 2022).
|
|
498
|
+
|
|
499
|
+
Uses this fit as **stage 1** and fits a **stage-2** model in
|
|
500
|
+
which ``name`` is a new construct whose indicators are the
|
|
501
|
+
first-order LV scores produced by stage 1. The four canonical
|
|
502
|
+
HOC types (R-R, R-F, F-R, F-F) follow from the existing first-
|
|
503
|
+
order modes in this config plus the ``mode`` chosen here.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
name: name of the second-order construct. Must not collide
|
|
507
|
+
with an existing LV or data column.
|
|
508
|
+
first_order: first-order LVs (from this fit) to roll into
|
|
509
|
+
the HOC. At least two are required.
|
|
510
|
+
mode: measurement mode of the HOC w.r.t. its first-order
|
|
511
|
+
indicators (:class:`.mode.Mode`).
|
|
512
|
+
structure: :class:`.config.Structure` of the stage-2 path
|
|
513
|
+
model. Must include ``name`` and must NOT include any
|
|
514
|
+
of the ``first_order`` LVs.
|
|
515
|
+
iterations, tolerance, missing_strategy: stage-2 ``Plspm``
|
|
516
|
+
fit options.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
a :class:`.higher_order.HigherOrder` instance exposing the
|
|
520
|
+
stage-2 fit (``refit()``) and HOC-specific accessors
|
|
521
|
+
(``loadings()``, ``summary()``).
|
|
522
|
+
"""
|
|
523
|
+
return HigherOrder(
|
|
524
|
+
self,
|
|
525
|
+
name=name,
|
|
526
|
+
first_order=first_order,
|
|
527
|
+
mode=mode,
|
|
528
|
+
structure=structure,
|
|
529
|
+
scheme=self.__scheme,
|
|
530
|
+
iterations=iterations,
|
|
531
|
+
tolerance=tolerance,
|
|
532
|
+
missing_strategy=missing_strategy,
|
|
533
|
+
)
|
|
534
|
+
|
|
476
535
|
def bootstrap(self) -> Bootstrap:
|
|
477
536
|
"""Gets the results of bootstrap validation, if requested
|
|
478
537
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openpls-engine
|
|
3
|
-
Version: 1.
|
|
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.
|
|
3
|
+
Version: 1.3.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, disjoint two-stage HOC) 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
|
|
@@ -56,6 +56,8 @@ attenuation correction in reflective models, PLSpredict out-of-sample
|
|
|
56
56
|
validation, IPMA (Importance-Performance Map Analysis),
|
|
57
57
|
two-stage moderation, FIMIX-PLS finite-mixture segmentation, the
|
|
58
58
|
Gaussian-copula endogeneity test (Park & Gupta 2012; Hult et al. 2018),
|
|
59
|
+
disjoint two-stage higher-order constructs (Sarstedt et al. 2019; Hair
|
|
60
|
+
et al. 2022),
|
|
59
61
|
and a
|
|
60
62
|
**quasi-Newton (BFGS) inner-weighting scheme** and a **Lohmöller PCA
|
|
61
63
|
scheme**, two new alternatives to the classical centroid, factorial, and
|
|
@@ -78,7 +80,7 @@ Hosted docs at [**openpls.app/engine**](https://openpls.app/engine):
|
|
|
78
80
|
- [Installation](https://openpls.app/engine/installation/) - pip and source installs.
|
|
79
81
|
- [Quickstart](https://openpls.app/engine/quickstart/) - end-to-end fit on the satisfaction dataset.
|
|
80
82
|
- [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).
|
|
83
|
+
- [API reference](https://openpls.app/engine/api/) - the full public surface (Plspm, Config, VIF, CTAPLS, PLSc, GaussianCopula, HigherOrder, IPMA, PLSpredict, Moderation, FIMIX, MGA, LongBootstrap).
|
|
82
84
|
- [Examples](https://openpls.app/engine/examples/) - runnable snippets for each advanced analysis.
|
|
83
85
|
- [Changelog](https://openpls.app/engine/changelog/) - version history with per-feature notes.
|
|
84
86
|
|
|
@@ -102,7 +104,7 @@ pip install openpls-engine
|
|
|
102
104
|
Or pin a specific version for reproducible analyses:
|
|
103
105
|
|
|
104
106
|
```sh
|
|
105
|
-
pip install openpls-engine==1.
|
|
107
|
+
pip install openpls-engine==1.3.0
|
|
106
108
|
```
|
|
107
109
|
|
|
108
110
|
To work from source instead:
|
|
@@ -9,6 +9,7 @@ openpls/cta.py
|
|
|
9
9
|
openpls/estimator.py
|
|
10
10
|
openpls/fimix.py
|
|
11
11
|
openpls/fit.py
|
|
12
|
+
openpls/higher_order.py
|
|
12
13
|
openpls/htmt.py
|
|
13
14
|
openpls/htmt2.py
|
|
14
15
|
openpls/inner_model.py
|
|
@@ -40,6 +41,7 @@ tests/test_cta.py
|
|
|
40
41
|
tests/test_estimator.py
|
|
41
42
|
tests/test_fimix.py
|
|
42
43
|
tests/test_fit.py
|
|
44
|
+
tests/test_higher_order.py
|
|
43
45
|
tests/test_htmt.py
|
|
44
46
|
tests/test_htmt2.py
|
|
45
47
|
tests/test_inner_summary_metrics.py
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "openpls-engine"
|
|
7
|
-
version = "1.
|
|
8
|
-
description = "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."
|
|
7
|
+
version = "1.3.0"
|
|
8
|
+
description = "PLS-SEM compute engine, a maintained fork of plspm-python with advanced analyses (PLSc, IPMA, PLSpredict, moderation, FIMIX-PLS, Gaussian-copula endogeneity, disjoint two-stage HOC) and extended metrics (SRMR, d_ULS, HTMT, HTMT2, Q², MGA) powering OpenPLS."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "GPL-3.0-or-later" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
import openpls.config as c
|
|
15
|
+
from openpls.config import MV
|
|
16
|
+
from openpls.higher_order import HigherOrder
|
|
17
|
+
from openpls.mode import Mode
|
|
18
|
+
from openpls.plspm import Plspm
|
|
19
|
+
from openpls.scheme import Scheme
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _hoc_dataset(
|
|
23
|
+
n: int = 600,
|
|
24
|
+
loading: float = 0.8,
|
|
25
|
+
hoc_to_y: float = 0.6,
|
|
26
|
+
seed: int = 0,
|
|
27
|
+
) -> pd.DataFrame:
|
|
28
|
+
"""Generate a 3-first-order + 1-target dataset.
|
|
29
|
+
|
|
30
|
+
The three first-order factors share a common HOC eta_h. The target
|
|
31
|
+
Y is driven by eta_h directly. With ``loading`` close to 1 the
|
|
32
|
+
first-order LVs are excellent reflectors of eta_h and the HOC path
|
|
33
|
+
to Y must approach ``hoc_to_y``.
|
|
34
|
+
"""
|
|
35
|
+
rng = np.random.default_rng(seed)
|
|
36
|
+
eta_h = rng.standard_normal(n)
|
|
37
|
+
eta_y = hoc_to_y * eta_h + rng.standard_normal(n) * np.sqrt(1 - hoc_to_y ** 2)
|
|
38
|
+
|
|
39
|
+
def block(prefix: str, p: int, factor: np.ndarray) -> pd.DataFrame:
|
|
40
|
+
err = rng.standard_normal((n, p)) * np.sqrt(1 - loading ** 2)
|
|
41
|
+
arr = loading * factor[:, None] + err
|
|
42
|
+
return pd.DataFrame(arr, columns=[f"{prefix}{i+1}" for i in range(p)])
|
|
43
|
+
|
|
44
|
+
# First-order LVs all reflect eta_h with small idiosyncratic shifts so
|
|
45
|
+
# they are clearly distinct constructs but driven by the same higher
|
|
46
|
+
# factor (the canonical HOC setup).
|
|
47
|
+
eta1 = eta_h + 0.2 * rng.standard_normal(n)
|
|
48
|
+
eta2 = eta_h + 0.2 * rng.standard_normal(n)
|
|
49
|
+
eta3 = eta_h + 0.2 * rng.standard_normal(n)
|
|
50
|
+
df = pd.concat(
|
|
51
|
+
[
|
|
52
|
+
block("x1_", 4, eta1),
|
|
53
|
+
block("x2_", 4, eta2),
|
|
54
|
+
block("x3_", 4, eta3),
|
|
55
|
+
block("y", 3, eta_y),
|
|
56
|
+
],
|
|
57
|
+
axis=1,
|
|
58
|
+
)
|
|
59
|
+
return df
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _stage1_config(df: pd.DataFrame, first_order_mode: Mode = Mode.A) -> c.Config:
|
|
63
|
+
structure = c.Structure()
|
|
64
|
+
structure.add_path(["X1"], ["Y"])
|
|
65
|
+
structure.add_path(["X2"], ["Y"])
|
|
66
|
+
structure.add_path(["X3"], ["Y"])
|
|
67
|
+
config = c.Config(structure.path(), scaled=False)
|
|
68
|
+
config.add_lv_with_columns_named("X1", first_order_mode, df, "x1_")
|
|
69
|
+
config.add_lv_with_columns_named("X2", first_order_mode, df, "x2_")
|
|
70
|
+
config.add_lv_with_columns_named("X3", first_order_mode, df, "x3_")
|
|
71
|
+
config.add_lv_with_columns_named("Y", Mode.A, df, "y")
|
|
72
|
+
return config
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _stage2_structure() -> c.Structure:
|
|
76
|
+
structure = c.Structure()
|
|
77
|
+
structure.add_path(["HOC"], ["Y"])
|
|
78
|
+
return structure
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_higher_order_type_i_reflective_reflective():
|
|
82
|
+
df = _hoc_dataset(seed=1)
|
|
83
|
+
base = Plspm(df, _stage1_config(df, Mode.A), Scheme.CENTROID)
|
|
84
|
+
hoc = base.higher_order(
|
|
85
|
+
name="HOC",
|
|
86
|
+
first_order=["X1", "X2", "X3"],
|
|
87
|
+
mode=Mode.A,
|
|
88
|
+
structure=_stage2_structure(),
|
|
89
|
+
)
|
|
90
|
+
# HOC loadings on its first-order indicators should all be strong
|
|
91
|
+
# and positive: a Type-I HOC where the first-order LVs are excellent
|
|
92
|
+
# reflectors of the same higher factor.
|
|
93
|
+
loadings = hoc.loadings()
|
|
94
|
+
assert (loadings > 0.7).all()
|
|
95
|
+
# HOC path to Y should be in the right direction and substantial.
|
|
96
|
+
paths = hoc.path_coefficients()
|
|
97
|
+
assert paths.loc["Y", "HOC"] > 0.3
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_higher_order_type_ii_reflective_formative():
|
|
101
|
+
df = _hoc_dataset(seed=2)
|
|
102
|
+
base = Plspm(df, _stage1_config(df, Mode.A), Scheme.CENTROID)
|
|
103
|
+
hoc = base.higher_order(
|
|
104
|
+
name="HOC",
|
|
105
|
+
first_order=["X1", "X2", "X3"],
|
|
106
|
+
mode=Mode.B,
|
|
107
|
+
structure=_stage2_structure(),
|
|
108
|
+
)
|
|
109
|
+
assert hoc.hoc_mode() == Mode.B
|
|
110
|
+
# Mode B HOC: formative weights, not loadings. summary() reports them.
|
|
111
|
+
summary = hoc.summary()
|
|
112
|
+
assert "weight" in summary.columns
|
|
113
|
+
assert (summary["weight"] > 0).all()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_higher_order_type_iii_formative_reflective():
|
|
117
|
+
df = _hoc_dataset(seed=3)
|
|
118
|
+
base = Plspm(df, _stage1_config(df, Mode.B), Scheme.CENTROID)
|
|
119
|
+
hoc = base.higher_order(
|
|
120
|
+
name="HOC",
|
|
121
|
+
first_order=["X1", "X2", "X3"],
|
|
122
|
+
mode=Mode.A,
|
|
123
|
+
structure=_stage2_structure(),
|
|
124
|
+
)
|
|
125
|
+
loadings = hoc.loadings()
|
|
126
|
+
# First-order LVs are formative composites of the HOC indicators —
|
|
127
|
+
# they should still show consistent direction onto the HOC.
|
|
128
|
+
assert (loadings > 0).all()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_higher_order_type_iv_formative_formative():
|
|
132
|
+
df = _hoc_dataset(seed=4)
|
|
133
|
+
base = Plspm(df, _stage1_config(df, Mode.B), Scheme.CENTROID)
|
|
134
|
+
hoc = base.higher_order(
|
|
135
|
+
name="HOC",
|
|
136
|
+
first_order=["X1", "X2", "X3"],
|
|
137
|
+
mode=Mode.B,
|
|
138
|
+
structure=_stage2_structure(),
|
|
139
|
+
)
|
|
140
|
+
summary = hoc.summary()
|
|
141
|
+
assert "weight" in summary.columns
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_higher_order_stage2_has_hoc_and_drops_first_order():
|
|
145
|
+
df = _hoc_dataset(seed=5)
|
|
146
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
147
|
+
hoc = base.higher_order(
|
|
148
|
+
name="HOC",
|
|
149
|
+
first_order=["X1", "X2", "X3"],
|
|
150
|
+
mode=Mode.A,
|
|
151
|
+
structure=_stage2_structure(),
|
|
152
|
+
)
|
|
153
|
+
refit = hoc.refit()
|
|
154
|
+
lvs = list(refit.path_coefficients().index)
|
|
155
|
+
assert "HOC" in lvs
|
|
156
|
+
assert "Y" in lvs
|
|
157
|
+
for old in ["X1", "X2", "X3"]:
|
|
158
|
+
assert old not in lvs
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_higher_order_recovers_hoc_path_close_to_truth():
|
|
162
|
+
"""With strong loadings and a known underlying eta_h → Y coefficient,
|
|
163
|
+
the disjoint two-stage HOC path estimate should land near the truth."""
|
|
164
|
+
truth = 0.7
|
|
165
|
+
df = _hoc_dataset(n=1500, loading=0.9, hoc_to_y=truth, seed=42)
|
|
166
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
167
|
+
hoc = base.higher_order(
|
|
168
|
+
name="HOC",
|
|
169
|
+
first_order=["X1", "X2", "X3"],
|
|
170
|
+
mode=Mode.A,
|
|
171
|
+
structure=_stage2_structure(),
|
|
172
|
+
)
|
|
173
|
+
estimate = float(hoc.path_coefficients().loc["Y", "HOC"])
|
|
174
|
+
assert abs(estimate - truth) < 0.05
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_higher_order_rejects_duplicate_first_order():
|
|
178
|
+
df = _hoc_dataset()
|
|
179
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
180
|
+
with pytest.raises(ValueError):
|
|
181
|
+
base.higher_order(
|
|
182
|
+
name="HOC",
|
|
183
|
+
first_order=["X1", "X1"],
|
|
184
|
+
mode=Mode.A,
|
|
185
|
+
structure=_stage2_structure(),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_higher_order_rejects_unknown_first_order():
|
|
190
|
+
df = _hoc_dataset()
|
|
191
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
192
|
+
with pytest.raises(ValueError):
|
|
193
|
+
base.higher_order(
|
|
194
|
+
name="HOC",
|
|
195
|
+
first_order=["X1", "NOT_IN_MODEL"],
|
|
196
|
+
mode=Mode.A,
|
|
197
|
+
structure=_stage2_structure(),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_higher_order_rejects_name_collision():
|
|
202
|
+
df = _hoc_dataset()
|
|
203
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
204
|
+
with pytest.raises(ValueError):
|
|
205
|
+
base.higher_order(
|
|
206
|
+
name="X1", # already an LV
|
|
207
|
+
first_order=["X1", "X2", "X3"],
|
|
208
|
+
mode=Mode.A,
|
|
209
|
+
structure=_stage2_structure(),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_higher_order_rejects_first_order_in_stage2_structure():
|
|
214
|
+
df = _hoc_dataset()
|
|
215
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
216
|
+
bad = c.Structure()
|
|
217
|
+
bad.add_path(["HOC"], ["Y"])
|
|
218
|
+
bad.add_path(["X1"], ["Y"]) # first-order LV in stage-2 — must reject
|
|
219
|
+
with pytest.raises(ValueError):
|
|
220
|
+
base.higher_order(
|
|
221
|
+
name="HOC",
|
|
222
|
+
first_order=["X1", "X2", "X3"],
|
|
223
|
+
mode=Mode.A,
|
|
224
|
+
structure=bad,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_higher_order_rejects_unknown_stage2_lv():
|
|
229
|
+
df = _hoc_dataset()
|
|
230
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
231
|
+
bad = c.Structure()
|
|
232
|
+
bad.add_path(["HOC"], ["NEW_LV"]) # not in base config
|
|
233
|
+
with pytest.raises(ValueError):
|
|
234
|
+
base.higher_order(
|
|
235
|
+
name="HOC",
|
|
236
|
+
first_order=["X1", "X2", "X3"],
|
|
237
|
+
mode=Mode.A,
|
|
238
|
+
structure=bad,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_higher_order_requires_hoc_in_structure():
|
|
243
|
+
df = _hoc_dataset()
|
|
244
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
245
|
+
bad = c.Structure()
|
|
246
|
+
bad.add_path(["Y"], ["X2"]) # any path that does not mention HOC
|
|
247
|
+
with pytest.raises(ValueError):
|
|
248
|
+
base.higher_order(
|
|
249
|
+
name="HOC",
|
|
250
|
+
first_order=["X1", "X2", "X3"],
|
|
251
|
+
mode=Mode.A,
|
|
252
|
+
structure=bad,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_higher_order_requires_two_first_order():
|
|
257
|
+
df = _hoc_dataset()
|
|
258
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
259
|
+
with pytest.raises(ValueError):
|
|
260
|
+
base.higher_order(
|
|
261
|
+
name="HOC",
|
|
262
|
+
first_order=["X1"],
|
|
263
|
+
mode=Mode.A,
|
|
264
|
+
structure=_stage2_structure(),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_higher_order_base_and_refit_are_distinct_fits():
|
|
269
|
+
df = _hoc_dataset()
|
|
270
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
271
|
+
hoc = base.higher_order(
|
|
272
|
+
name="HOC",
|
|
273
|
+
first_order=["X1", "X2", "X3"],
|
|
274
|
+
mode=Mode.A,
|
|
275
|
+
structure=_stage2_structure(),
|
|
276
|
+
)
|
|
277
|
+
assert hoc.base() is base
|
|
278
|
+
assert hoc.refit() is not base
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_higher_order_indicator_columns_use_namespaced_names():
|
|
282
|
+
df = _hoc_dataset()
|
|
283
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
284
|
+
hoc = base.higher_order(
|
|
285
|
+
name="HOC",
|
|
286
|
+
first_order=["X1", "X2", "X3"],
|
|
287
|
+
mode=Mode.A,
|
|
288
|
+
structure=_stage2_structure(),
|
|
289
|
+
)
|
|
290
|
+
cols = hoc.indicator_columns()
|
|
291
|
+
assert cols == {"X1": "HOC__X1", "X2": "HOC__X2", "X3": "HOC__X3"}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_higher_order_summary_shape_and_columns_mode_a():
|
|
295
|
+
df = _hoc_dataset()
|
|
296
|
+
base = Plspm(df, _stage1_config(df), Scheme.CENTROID)
|
|
297
|
+
hoc = base.higher_order(
|
|
298
|
+
name="HOC",
|
|
299
|
+
first_order=["X1", "X2", "X3"],
|
|
300
|
+
mode=Mode.A,
|
|
301
|
+
structure=_stage2_structure(),
|
|
302
|
+
)
|
|
303
|
+
summary = hoc.summary()
|
|
304
|
+
assert list(summary.columns) == ["first_order", "loading", "stage1_r_squared"]
|
|
305
|
+
assert list(summary["first_order"]) == ["X1", "X2", "X3"]
|
|
306
|
+
# X1/X2/X3 are exogenous in stage 1 → R² reported as 0 by inner_summary.
|
|
307
|
+
assert (summary["stage1_r_squared"] == 0.0).all()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_higher_order_can_chain_nested_hoc():
|
|
311
|
+
"""Calling higher_order on the stage-2 refit should chain — a HOC
|
|
312
|
+
can in turn become a first-order of a third-order construct."""
|
|
313
|
+
# Build a model with two sibling HOCs both pointing at Y.
|
|
314
|
+
df = _hoc_dataset(seed=11)
|
|
315
|
+
# Add a fourth first-order block so we can group (X1,X2) into HOC_A
|
|
316
|
+
# and (X3,X4) into HOC_B.
|
|
317
|
+
rng = np.random.default_rng(13)
|
|
318
|
+
eta = df["x1_1"] * 0.6 + rng.standard_normal(len(df)) * 0.4
|
|
319
|
+
err = rng.standard_normal((len(df), 4)) * np.sqrt(1 - 0.7 ** 2)
|
|
320
|
+
block_4 = pd.DataFrame(
|
|
321
|
+
0.7 * eta.to_numpy()[:, None] + err,
|
|
322
|
+
columns=[f"x4_{i+1}" for i in range(4)],
|
|
323
|
+
index=df.index,
|
|
324
|
+
)
|
|
325
|
+
df2 = pd.concat([df, block_4], axis=1)
|
|
326
|
+
structure = c.Structure()
|
|
327
|
+
structure.add_path(["X1"], ["Y"])
|
|
328
|
+
structure.add_path(["X2"], ["Y"])
|
|
329
|
+
structure.add_path(["X3"], ["Y"])
|
|
330
|
+
structure.add_path(["X4"], ["Y"])
|
|
331
|
+
config = c.Config(structure.path(), scaled=False)
|
|
332
|
+
for lv in ["X1", "X2", "X3", "X4"]:
|
|
333
|
+
config.add_lv_with_columns_named(lv, Mode.A, df2, lv.lower() + "_")
|
|
334
|
+
config.add_lv_with_columns_named("Y", Mode.A, df2, "y")
|
|
335
|
+
base = Plspm(df2, config, Scheme.CENTROID)
|
|
336
|
+
|
|
337
|
+
# The disjoint API rolls one HOC at a time. We do HOC_A first into
|
|
338
|
+
# an intermediate stage 2 that still keeps X3 and X4 as siblings.
|
|
339
|
+
s2a = c.Structure()
|
|
340
|
+
s2a.add_path(["HOC_A"], ["Y"])
|
|
341
|
+
s2a.add_path(["X3"], ["Y"])
|
|
342
|
+
s2a.add_path(["X4"], ["Y"])
|
|
343
|
+
stage2 = base.higher_order(
|
|
344
|
+
name="HOC_A", first_order=["X1", "X2"], mode=Mode.A, structure=s2a
|
|
345
|
+
).refit()
|
|
346
|
+
# Then a second HOC on top of the stage-2 fit.
|
|
347
|
+
s3 = c.Structure()
|
|
348
|
+
s3.add_path(["HOC_A"], ["Y"])
|
|
349
|
+
s3.add_path(["HOC_B"], ["Y"])
|
|
350
|
+
stage3 = stage2.higher_order(
|
|
351
|
+
name="HOC_B", first_order=["X3", "X4"], mode=Mode.A, structure=s3
|
|
352
|
+
)
|
|
353
|
+
paths = stage3.path_coefficients()
|
|
354
|
+
assert "HOC_A" in paths.index
|
|
355
|
+
assert "HOC_B" in paths.index
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|