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.
Files changed (71) hide show
  1. {openpls_engine-1.2.0/openpls_engine.egg-info → openpls_engine-1.3.0}/PKG-INFO +6 -4
  2. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/README.md +4 -2
  3. openpls_engine-1.3.0/openpls/higher_order.py +291 -0
  4. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/plspm.py +59 -0
  5. {openpls_engine-1.2.0 → openpls_engine-1.3.0/openpls_engine.egg-info}/PKG-INFO +6 -4
  6. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls_engine.egg-info/SOURCES.txt +2 -0
  7. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/pyproject.toml +2 -2
  8. openpls_engine-1.3.0/tests/test_higher_order.py +355 -0
  9. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/LICENSE +0 -0
  10. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/__init__.py +0 -0
  11. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/bootstrap.py +0 -0
  12. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/config.py +0 -0
  13. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/copula.py +0 -0
  14. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/cta.py +0 -0
  15. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/estimator.py +0 -0
  16. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/fimix.py +0 -0
  17. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/fit.py +0 -0
  18. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/htmt.py +0 -0
  19. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/htmt2.py +0 -0
  20. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/inner_model.py +0 -0
  21. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/inner_summary.py +0 -0
  22. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/ipma.py +0 -0
  23. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/long_bootstrap.py +0 -0
  24. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/mga.py +0 -0
  25. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/mode.py +0 -0
  26. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/moderation.py +0 -0
  27. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/outer_model.py +0 -0
  28. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/plsc.py +0 -0
  29. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/predict.py +0 -0
  30. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/q_squared.py +0 -0
  31. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/scale.py +0 -0
  32. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/scheme.py +0 -0
  33. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/unidimensionality.py +0 -0
  34. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/util.py +0 -0
  35. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/vif.py +0 -0
  36. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls/weights.py +0 -0
  37. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls_engine.egg-info/dependency_links.txt +0 -0
  38. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls_engine.egg-info/requires.txt +0 -0
  39. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/openpls_engine.egg-info/top_level.txt +0 -0
  40. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/setup.cfg +0 -0
  41. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_config.py +0 -0
  42. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_copula.py +0 -0
  43. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_cta.py +0 -0
  44. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_estimator.py +0 -0
  45. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_fimix.py +0 -0
  46. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_fit.py +0 -0
  47. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_htmt.py +0 -0
  48. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_htmt2.py +0 -0
  49. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_inner_summary_metrics.py +0 -0
  50. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_ipma.py +0 -0
  51. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_long_bootstrap.py +0 -0
  52. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_lv_mv_name_collision.py +0 -0
  53. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_mga.py +0 -0
  54. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_missing_strategy.py +0 -0
  55. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_moderation.py +0 -0
  56. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_path_recovery_synthetic.py +0 -0
  57. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_plsc.py +0 -0
  58. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_predict.py +0 -0
  59. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_q_squared.py +0 -0
  60. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_redundancy_analysis_mode_b.py +0 -0
  61. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_regression_bootstrap.py +0 -0
  62. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_regression_metric.py +0 -0
  63. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_regression_nonmetric.py +0 -0
  64. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_regression_seminr.py +0 -0
  65. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_scheme_equivalence_two_lv.py +0 -0
  66. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_scheme_newton.py +0 -0
  67. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_scheme_pca.py +0 -0
  68. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_sign_convention.py +0 -0
  69. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_unidimensionality_fallback.py +0 -0
  70. {openpls_engine-1.2.0 → openpls_engine-1.3.0}/tests/test_util.py +0 -0
  71. {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.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.
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.2.0
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.2.0
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.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.
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.2.0
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.2.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) 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