openpls-engine 0.7.0a2__py3-none-any.whl

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.
plspm/fit.py ADDED
@@ -0,0 +1,112 @@
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
+
21
+ import plspm.config as c
22
+
23
+
24
+ class ModelFit:
25
+ """Model-fit indices comparing observed vs model-implied indicator correlations.
26
+
27
+ The saturated model-implied correlation is
28
+
29
+ Σ̂ = Λ Φ Λᵀ with diag(Σ̂) = 1
30
+
31
+ where Λ is the n × k indicator-loading matrix (one non-zero per row,
32
+ keyed to the LV the indicator belongs to) and Φ is the k × k LV
33
+ correlation matrix. SRMR averages squared residuals over the strict
34
+ lower triangle (the diagonal is forced to 1 by construction);
35
+ d_ULS is the unweighted sum of those squared residuals.
36
+
37
+ See Henseler et al. (2014) for the construction; Henseler et al. (2014)
38
+ suggest SRMR < 0.08 as an acceptable-fit threshold.
39
+ """
40
+
41
+ def __init__(self, config: c.Config, data: pd.DataFrame, scores: pd.DataFrame, outer_model: pd.DataFrame):
42
+ # Build the indicator → LV map and the ordered indicator list
43
+ # following the path-matrix LV order (which is the same order as
44
+ # `scores.columns` produced by the estimator).
45
+ lv_names = list(scores.columns)
46
+ ind_to_lv: dict[str, str] = {}
47
+ inds: list[str] = []
48
+ for lv in lv_names:
49
+ for mv in config.mvs(lv):
50
+ if mv in data.columns and mv in outer_model.index:
51
+ ind_to_lv[mv] = lv
52
+ inds.append(mv)
53
+
54
+ if len(inds) < 2:
55
+ self.__srmr = float("nan")
56
+ self.__d_uls = float("nan")
57
+ self.__residuals = pd.DataFrame()
58
+ return
59
+
60
+ n_ind, n_lv = len(inds), len(lv_names)
61
+ loadings = outer_model.loc[inds, "loading"].to_numpy(dtype=float)
62
+ lv_idx = np.array([lv_names.index(ind_to_lv[i]) for i in inds])
63
+ Lambda = np.zeros((n_ind, n_lv), dtype=float)
64
+ Lambda[np.arange(n_ind), lv_idx] = loadings
65
+
66
+ Phi = scores.corr().reindex(index=lv_names, columns=lv_names).to_numpy()
67
+ S = data[inds].corr().to_numpy()
68
+
69
+ if np.isnan(Phi).any() or np.isnan(S).any():
70
+ self.__srmr = float("nan")
71
+ self.__d_uls = float("nan")
72
+ self.__residuals = pd.DataFrame()
73
+ return
74
+
75
+ implied = Lambda @ Phi @ Lambda.T
76
+ np.fill_diagonal(implied, 1.0)
77
+ resid = S - implied
78
+
79
+ iu = np.tril_indices(n_ind, k=-1)
80
+ self.__srmr = float(np.sqrt(np.mean(resid[iu] ** 2)))
81
+ self.__d_uls = float(np.sum(resid[iu] ** 2))
82
+ self.__residuals = pd.DataFrame(resid, index=inds, columns=inds)
83
+
84
+ def srmr(self) -> float:
85
+ """Standardized Root Mean Square Residual.
86
+
87
+ ``< 0.08`` is the conventional acceptable-fit threshold
88
+ (Henseler et al., 2014).
89
+ """
90
+ return self.__srmr
91
+
92
+ def d_uls(self) -> float:
93
+ """d_ULS — squared Euclidean distance between observed and
94
+ model-implied indicator correlation matrices (strict lower triangle).
95
+ """
96
+ return self.__d_uls
97
+
98
+ def residuals(self) -> pd.DataFrame:
99
+ """Indicator-correlation residual matrix (observed − model-implied).
100
+
101
+ Returns:
102
+ a square DataFrame indexed by indicator name. Empty if the fit
103
+ could not be computed (e.g. fewer than two indicators).
104
+ """
105
+ return self.__residuals
106
+
107
+ def summary(self) -> pd.DataFrame:
108
+ """One-row summary of the fit indices."""
109
+ return pd.DataFrame(
110
+ {"srmr": [self.__srmr], "d_uls": [self.__d_uls]},
111
+ index=["saturated"],
112
+ )
plspm/htmt.py ADDED
@@ -0,0 +1,119 @@
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
+
21
+ import plspm.config as c
22
+
23
+
24
+ class HTMT:
25
+ """Heterotrait-Monotrait Ratio of Correlations (Henseler, Ringle & Sarstedt, 2015).
26
+
27
+ For each pair ``(i, j)`` of latent variables::
28
+
29
+ HTMT(i, j) = mean(|corr(x_i, x_j)|) / sqrt( mean_within(i) * mean_within(j) )
30
+
31
+ where ``mean_within`` is the mean of *off-diagonal* absolute correlations
32
+ among indicators of the same construct. Single-indicator constructs
33
+ cannot have a within-block mean and are skipped (HTMT is NaN for pairs
34
+ involving them).
35
+
36
+ Henseler et al. (2015) suggest HTMT < 0.85 (conservative) or < 0.90
37
+ (liberal) as the discriminant-validity threshold.
38
+ """
39
+
40
+ def __init__(self, config: c.Config, data: pd.DataFrame):
41
+ lv_names = [lv for lv in config.path().columns]
42
+
43
+ all_inds: list[str] = []
44
+ seen: set[str] = set()
45
+ lv_inds: dict[str, list[str]] = {}
46
+ for lv in lv_names:
47
+ inds: list[str] = []
48
+ for mv in config.mvs(lv):
49
+ if mv in data.columns:
50
+ inds.append(mv)
51
+ if mv not in seen:
52
+ seen.add(mv)
53
+ all_inds.append(mv)
54
+ lv_inds[lv] = inds
55
+
56
+ matrix = pd.DataFrame(np.nan, index=lv_names, columns=lv_names, dtype=float)
57
+ for lv in lv_names:
58
+ matrix.loc[lv, lv] = np.nan
59
+
60
+ if len(lv_names) < 2 or not all_inds:
61
+ self.__matrix = matrix
62
+ return
63
+
64
+ corr_abs = data[all_inds].corr().abs()
65
+
66
+ def _mean_within(inds: list[str]) -> float | None:
67
+ valid = [i for i in inds if i in corr_abs.index]
68
+ if len(valid) < 2:
69
+ return None
70
+ sub = corr_abs.loc[valid, valid].to_numpy()
71
+ n = sub.shape[0]
72
+ # Off-diagonal upper triangle sum; off-diag count = n*(n-1)/2.
73
+ return float(np.triu(sub, k=1).sum()) / (n * (n - 1) / 2.0)
74
+
75
+ def _mean_between(a: list[str], b: list[str]) -> float | None:
76
+ va = [i for i in a if i in corr_abs.index]
77
+ vb = [i for i in b if i in corr_abs.index]
78
+ if not va or not vb:
79
+ return None
80
+ return float(corr_abs.loc[va, vb].to_numpy().mean())
81
+
82
+ within = {lv: _mean_within(lv_inds[lv]) for lv in lv_names}
83
+
84
+ for i, lv_a in enumerate(lv_names):
85
+ for lv_b in lv_names[i + 1 :]:
86
+ mw_a, mw_b = within[lv_a], within[lv_b]
87
+ if mw_a is None or mw_b is None or mw_a <= 0 or mw_b <= 0:
88
+ continue
89
+ mb = _mean_between(lv_inds[lv_a], lv_inds[lv_b])
90
+ if mb is None:
91
+ continue
92
+ ratio = mb / float(np.sqrt(mw_a * mw_b))
93
+ matrix.loc[lv_a, lv_b] = ratio
94
+ matrix.loc[lv_b, lv_a] = ratio
95
+
96
+ self.__matrix = matrix
97
+
98
+ def matrix(self) -> pd.DataFrame:
99
+ """Full symmetric HTMT matrix indexed by latent variable name.
100
+
101
+ Diagonal entries are ``NaN`` (HTMT is undefined within a construct).
102
+ Pairs that involve a single-indicator construct are also ``NaN``.
103
+ """
104
+ return self.__matrix
105
+
106
+ def pairs(self) -> pd.DataFrame:
107
+ """Long-format view of the HTMT matrix — one row per unique LV pair.
108
+
109
+ Useful for tabular display and CSV export. Columns: ``lv_a``,
110
+ ``lv_b``, ``htmt``. Pairs with undefined HTMT (NaN) are omitted.
111
+ """
112
+ names = list(self.__matrix.index)
113
+ rows = []
114
+ for i, lv_a in enumerate(names):
115
+ for lv_b in names[i + 1 :]:
116
+ val = self.__matrix.loc[lv_a, lv_b]
117
+ if pd.notna(val):
118
+ rows.append({"lv_a": lv_a, "lv_b": lv_b, "htmt": float(val)})
119
+ return pd.DataFrame(rows, columns=["lv_a", "lv_b", "htmt"])
plspm/inner_model.py ADDED
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/python3
2
+ #
3
+ # Copyright (C) 2019 Google Inc.
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 pandas as pd
19
+ import statsmodels.api as sm
20
+
21
+
22
+ def _summary(dv, regression):
23
+ summary = pd.DataFrame(0, columns=['from', 'to', 'estimate', 'std error', 't', 'p>|t|'], index=regression.params.index)
24
+ summary['to'] = dv
25
+ summary['from'] = regression.params.index
26
+ summary['estimate'] = regression.params
27
+ summary['std error'] = regression.bse
28
+ summary['t'] = regression.tvalues
29
+ summary['p>|t|'] = regression.pvalues
30
+ summary['index'] = summary['from'] + " -> " + summary['to']
31
+ return summary.drop(['const']).reset_index(drop=True)
32
+
33
+
34
+ def _effects(path: pd.DataFrame):
35
+ indirect_paths = pd.DataFrame(0, index=path.index, columns=path.columns)
36
+ effects = pd.DataFrame({"from": pd.Series(dtype="str"),
37
+ "to": pd.Series(dtype="str"),
38
+ "direct": pd.Series(dtype="float"),
39
+ "indirect": pd.Series(dtype="float"),
40
+ "total": pd.Series(dtype="float")})
41
+ num_lvs = len(list(path))
42
+ if (num_lvs == 2):
43
+ total_paths = path
44
+ else:
45
+ path_effects = {}
46
+ path_effects[0] = path
47
+ for i in range(1, num_lvs):
48
+ path_effects[i] = path_effects[i - 1].dot(path)
49
+ indirect_paths = indirect_paths + path_effects[i]
50
+ total_paths = path + indirect_paths
51
+ for from_lv in list(path):
52
+ for to_lv in list(path):
53
+ if from_lv != to_lv and total_paths.loc[to_lv, from_lv] != 0:
54
+ effect = pd.DataFrame([{
55
+ "from": from_lv,
56
+ "to": to_lv,
57
+ "direct": path.loc[to_lv, from_lv],
58
+ "indirect": indirect_paths.loc[to_lv, from_lv],
59
+ "total": total_paths.loc[to_lv, from_lv]
60
+ }], index=[from_lv + " -> " + to_lv])
61
+ effects = pd.concat([effects, effect])
62
+ return effects
63
+
64
+
65
+ class InnerModel:
66
+ """Internal class that calculates the attributes of the inner model. Use the methods :meth:`~plspm.Plspm.inner_model`, :meth:`~plspm.Plspm.path_coefficients`, and :meth:`~plspm.Plspm.effects` defined on :class:`~.plspm.Plspm` to retrieve the inner model characteristics."""
67
+ def __init__(self, path: pd.DataFrame, scores: pd.DataFrame):
68
+ self.__summaries = None
69
+ self.__r_squared = pd.Series(0.0, index=path.index, name="r_squared")
70
+ self.__r_squared_adj = pd.Series(0.0, index=path.index, name="r_squared_adj")
71
+ self.__path_coefficients = pd.DataFrame(0.0, columns=path.columns, index=path.index)
72
+ endogenous = path.sum(axis=1).astype(bool)
73
+ self.__endogenous = list(endogenous[endogenous == True].index)
74
+ rows = scores.shape[0]
75
+ for dv in self.__endogenous:
76
+ ivs = path.loc[dv][path.loc[dv] == 1].index
77
+ exogenous = sm.add_constant(scores.loc[:, ivs])
78
+ regression = sm.OLS(scores.loc[:, dv], exogenous).fit()
79
+ self.__path_coefficients.loc[dv, ivs] = regression.params
80
+ rsquared = regression.rsquared
81
+ self.__r_squared.loc[dv] = rsquared
82
+ self.__r_squared_adj.loc[dv] = 1 - (1 - rsquared) * (rows - 1) / (rows - path.loc[dv].sum() - 1)
83
+ self.__summaries = pd.concat([self.__summaries, _summary(dv, regression)]).reset_index(drop=True)
84
+ self.__effects = _effects(self.__path_coefficients)
85
+
86
+ def path_coefficients(self) -> pd.DataFrame:
87
+ """Internal method that returns the path coefficients of the inner model."""
88
+ return self.__path_coefficients
89
+
90
+ def r_squared(self) -> pd.Series:
91
+ """Internal method that returns r squared for the latent variables."""
92
+ return self.__r_squared
93
+
94
+ def r_squared_adj(self) -> pd.Series:
95
+ """Internal method that returns adjusted r squared for the latent variables."""
96
+ return self.__r_squared_adj
97
+
98
+ def inner_model(self) -> pd.DataFrame:
99
+ """Internal method that returns summaries of the characteristics of the inner model for each latent variable."""
100
+ return self.__summaries.set_index(['index'])
101
+
102
+ def effects(self) -> pd.DataFrame:
103
+ """Internal method that returns indirect, direct, and total effects for each path in the model."""
104
+ return self.__effects
105
+
106
+ def endogenous(self) -> list:
107
+ """Internal method that returns a list of the endogenous latent variables."""
108
+ return self.__endogenous
plspm/inner_summary.py ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/python3
2
+ #
3
+ # Copyright (C) 2019 Google Inc.
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 math
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from plspm.config import Config
24
+ from plspm.mode import Mode
25
+
26
+
27
+ class InnerSummary:
28
+ """Internal class that computes a summary of the inner model. Use the methods :meth:`~plspm.Plspm.inner_summary` and :meth:`~plspm.Plspm.goodness_of_fit` defined on :class:`~.plspm.Plspm` to retrieve the inner model characteristics."""
29
+
30
+ def __init__(self, config: Config, r_squared: pd.Series, r_squared_adj: pd.Series, outer_model: pd.DataFrame,
31
+ n_obs: int | None = None):
32
+ path = config.path()
33
+ lv_type = path.sum(axis=1).astype(bool)
34
+ lv_type.name = "type"
35
+ lv_type_text = lv_type.replace(False, "Exogenous").replace(True, "Endogenous")
36
+ block_communality = pd.Series(np.nan, index=path.index, name="block_communality")
37
+ mean_redundancy = pd.Series(np.nan, index=path.index, name="mean_redundancy")
38
+ ave = pd.Series(np.nan, index=path.index, name="ave")
39
+ bic = pd.Series(np.nan, index=path.index, name="bic")
40
+ communality_aux = []
41
+ num_mvs_in_lv = []
42
+ for lv in list(config.path()):
43
+ communality = outer_model.loc[:, "communality"].loc[config.mvs(lv)]
44
+ block_communality.loc[lv] = communality.mean()
45
+ mean_redundancy.loc[lv] = outer_model.loc[:, "redundancy"].loc[config.mvs(lv)].mean()
46
+ if config.mode(lv) == Mode.A:
47
+ ave_numerator = communality.sum()
48
+ ave_denominator = ave_numerator + (1 - communality).sum()
49
+ ave.loc[lv] = ave_numerator / ave_denominator
50
+ if len(config.mvs(lv)) > 1:
51
+ num_mvs_in_lv.append(len(config.mvs(lv)))
52
+ communality_aux.append(block_communality.loc[lv])
53
+ # BIC for the OLS regression of the standardized LV scores on its
54
+ # k predictors. SSE = (1 - R²) · (n − 1) since variance(y) = 1.
55
+ if n_obs is not None:
56
+ k = int(path.loc[lv].sum())
57
+ r2 = float(r_squared.loc[lv]) if lv in r_squared.index else 0.0
58
+ if k > 0 and n_obs > k + 1:
59
+ sse = max((1.0 - r2) * (n_obs - 1), 1e-12)
60
+ bic.loc[lv] = n_obs * np.log(sse / n_obs) + (k + 1) * np.log(n_obs)
61
+ self.__summary = pd.concat([lv_type_text, r_squared, r_squared_adj, block_communality, mean_redundancy, ave, bic],
62
+ axis=1, sort=True)
63
+ if sum(num_mvs_in_lv) > 0:
64
+ mean_communality = sum(x * y for x, y in zip(communality_aux, num_mvs_in_lv)) / sum(num_mvs_in_lv)
65
+ r_squared_aux = r_squared * lv_type
66
+ self.__goodness_of_fit = np.sqrt(mean_communality * r_squared_aux[r_squared_aux != 0].mean())
67
+ else:
68
+ # All constructs are single-item so we can't calculate goodness-of-fit
69
+ self.__goodness_of_fit = float("NaN")
70
+
71
+ def summary(self) -> pd.DataFrame:
72
+ """Internal method that returns the summary of the inner model."""
73
+ return self.__summary
74
+
75
+ def goodness_of_fit(self) -> float:
76
+ """Internal method that returns the goodness-of-fit of the model."""
77
+ if math.isnan(self.__goodness_of_fit):
78
+ raise ValueError("Cannot calculate goodness-of-fit if all constructs are single-item.")
79
+ return self.__goodness_of_fit