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.
- openpls_engine-0.7.0a2.dist-info/METADATA +161 -0
- openpls_engine-0.7.0a2.dist-info/RECORD +24 -0
- openpls_engine-0.7.0a2.dist-info/WHEEL +5 -0
- openpls_engine-0.7.0a2.dist-info/licenses/LICENSE +674 -0
- openpls_engine-0.7.0a2.dist-info/top_level.txt +1 -0
- plspm/__init__.py +26 -0
- plspm/bootstrap.py +147 -0
- plspm/config.py +325 -0
- plspm/estimator.py +76 -0
- plspm/fit.py +112 -0
- plspm/htmt.py +119 -0
- plspm/inner_model.py +108 -0
- plspm/inner_summary.py +79 -0
- plspm/long_bootstrap.py +334 -0
- plspm/mga.py +222 -0
- plspm/mode.py +73 -0
- plspm/outer_model.py +43 -0
- plspm/plspm.py +246 -0
- plspm/q_squared.py +137 -0
- plspm/scale.py +108 -0
- plspm/scheme.py +68 -0
- plspm/unidimensionality.py +80 -0
- plspm/util.py +164 -0
- plspm/weights.py +193 -0
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
|