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/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2019 Google Inc.
|
|
4
|
+
# Copyright (C) 2026 OpenPLS contributors
|
|
5
|
+
#
|
|
6
|
+
# This program is free software: you can redistribute it and/or modify
|
|
7
|
+
# it under the terms of the GNU General Public License as published by
|
|
8
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
9
|
+
# (at your option) any later version.
|
|
10
|
+
#
|
|
11
|
+
# This program is distributed in the hope that it will be useful,
|
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
# GNU General Public License for more details.
|
|
15
|
+
#
|
|
16
|
+
# You should have received a copy of the GNU General Public License
|
|
17
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
20
|
+
|
|
21
|
+
name = "plspm"
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = version("openpls-engine")
|
|
25
|
+
except PackageNotFoundError:
|
|
26
|
+
__version__ = "0.0.0+unknown"
|
plspm/bootstrap.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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 time
|
|
19
|
+
from multiprocessing import Process, Queue
|
|
20
|
+
from queue import Empty
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
import plspm.config as c
|
|
26
|
+
import plspm.inner_model as im
|
|
27
|
+
import plspm.outer_model as om
|
|
28
|
+
from plspm.estimator import Estimator
|
|
29
|
+
from plspm.weights import WeightsCalculatorFactory
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _create_summary(data: pd.DataFrame, original):
|
|
33
|
+
summary = pd.DataFrame(0.0, index=data.columns, columns=["original", "mean", "std.error", "perc.025", "perc.975", "t stat."])
|
|
34
|
+
summary.loc[:, "mean"] = data.mean(axis=0)
|
|
35
|
+
summary.loc[:, "std.error"] = data.std(axis=0)
|
|
36
|
+
summary.loc[:, "perc.025"] = data.quantile(0.025, axis=0)
|
|
37
|
+
summary.loc[:, "perc.975"] = data.quantile(0.975, axis=0)
|
|
38
|
+
summary.loc[:, "original"] = original
|
|
39
|
+
summary.loc[:, "t stat."] = original / data.std(axis=0)
|
|
40
|
+
return summary
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BootstrapProcess(Process):
|
|
44
|
+
def __init__(self, queue: Queue, config: c.Config, data: pd.DataFrame, inner_model: im.InnerModel, calculator: WeightsCalculatorFactory, iterations: int):
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.__queue = queue
|
|
47
|
+
self.__config = config
|
|
48
|
+
self.__data = data
|
|
49
|
+
self.__inner_model = inner_model
|
|
50
|
+
self.__calculator = calculator
|
|
51
|
+
self.__iterations = iterations
|
|
52
|
+
|
|
53
|
+
def run(self):
|
|
54
|
+
weights = pd.DataFrame(columns=self.__data.columns, dtype="float")
|
|
55
|
+
r_squared = pd.DataFrame(columns=self.__inner_model.r_squared().index, dtype="float")
|
|
56
|
+
total_effects = pd.DataFrame(columns=self.__inner_model.effects().index, dtype="float")
|
|
57
|
+
paths = pd.DataFrame(columns=self.__inner_model.effects().index, dtype="float")
|
|
58
|
+
loadings = pd.DataFrame(columns=self.__data.columns, dtype="float")
|
|
59
|
+
|
|
60
|
+
observations = self.__data.shape[0]
|
|
61
|
+
estimator = Estimator(self.__config)
|
|
62
|
+
for i in range(0, self.__iterations):
|
|
63
|
+
try:
|
|
64
|
+
boot_observations = np.random.randint(observations, size=observations)
|
|
65
|
+
_final_data, _scores, _weights = estimator.estimate(self.__calculator, self.__data.iloc[boot_observations, :])
|
|
66
|
+
weights = pd.concat([weights, _weights.T], ignore_index = True)
|
|
67
|
+
inner_model = im.InnerModel(self.__config.path(), _scores)
|
|
68
|
+
r_squared = pd.concat([r_squared, inner_model.r_squared().to_frame().T], ignore_index=True)
|
|
69
|
+
total_effects = pd.concat([total_effects,
|
|
70
|
+
inner_model.effects().loc[:, "total"].to_frame().T], ignore_index=True)
|
|
71
|
+
paths = pd.concat([paths,
|
|
72
|
+
inner_model.effects().loc[:, "direct"].to_frame().T], ignore_index=True)
|
|
73
|
+
loadings = pd.concat([loadings,
|
|
74
|
+
(_scores.apply(lambda s: _final_data.corrwith(s)) * self.__config.odm(self.__config.path())).sum(axis=1).to_frame().T], ignore_index=True)
|
|
75
|
+
except:
|
|
76
|
+
pass
|
|
77
|
+
results = {}
|
|
78
|
+
results["weights"] = weights
|
|
79
|
+
results["r_squared"] = r_squared
|
|
80
|
+
results["total_effects"] = total_effects
|
|
81
|
+
results["paths"] = paths
|
|
82
|
+
results["loadings"] = loadings
|
|
83
|
+
self.__queue.put(results)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Bootstrap:
|
|
87
|
+
"""Performs bootstrap validation to determine the statistical significance of the model.
|
|
88
|
+
|
|
89
|
+
Setting ``bootstrap=True`` when constructing :class:`.Plspm` will perform bootstrap validation. Calling :meth:`~.Plspm.bootstrap` on :class:`.Plspm` will return an instance of this class, from which the bootstrapping results can be retrieved by calling the methods listed below.
|
|
90
|
+
"""
|
|
91
|
+
def __init__(self, config: c.Config, data: pd.DataFrame, inner_model: im.InnerModel, outer_model: om.OuterModel,
|
|
92
|
+
calculator: WeightsCalculatorFactory, iterations: int, num_processes: int):
|
|
93
|
+
weights = pd.DataFrame(columns=data.columns, dtype="float")
|
|
94
|
+
r_squared = pd.DataFrame(columns=inner_model.r_squared().index, dtype="float")
|
|
95
|
+
total_effects = pd.DataFrame(columns=inner_model.effects().index, dtype="float")
|
|
96
|
+
paths = pd.DataFrame(columns=inner_model.effects().index, dtype="float")
|
|
97
|
+
loadings = pd.DataFrame(columns=data.columns, dtype="float")
|
|
98
|
+
|
|
99
|
+
queue = Queue()
|
|
100
|
+
processes = []
|
|
101
|
+
for t in range(0, num_processes):
|
|
102
|
+
process = BootstrapProcess(queue, config, data, inner_model, calculator, iterations // num_processes)
|
|
103
|
+
process.start()
|
|
104
|
+
processes.append(process)
|
|
105
|
+
|
|
106
|
+
running = list(processes)
|
|
107
|
+
while running:
|
|
108
|
+
try:
|
|
109
|
+
while True:
|
|
110
|
+
results = queue.get(False)
|
|
111
|
+
weights = pd.concat([weights, results["weights"]])
|
|
112
|
+
r_squared = pd.concat([r_squared, results["r_squared"]])
|
|
113
|
+
total_effects = pd.concat([total_effects, results["total_effects"]])
|
|
114
|
+
paths = pd.concat([paths, results["paths"]])
|
|
115
|
+
loadings = pd.concat([loadings, results["loadings"]])
|
|
116
|
+
except Empty:
|
|
117
|
+
pass
|
|
118
|
+
time.sleep(1)
|
|
119
|
+
if not queue.empty():
|
|
120
|
+
continue
|
|
121
|
+
running = [process for process in running if process.is_alive()]
|
|
122
|
+
|
|
123
|
+
self.__weights = _create_summary(weights, outer_model.model().loc[:, "weight"])
|
|
124
|
+
self.__r_squared = _create_summary(r_squared, inner_model.r_squared()).loc[inner_model.endogenous(), :]
|
|
125
|
+
self.__total_effects = _create_summary(total_effects, inner_model.effects().loc[:, "total"])
|
|
126
|
+
self.__paths = _create_summary(paths, inner_model.effects().loc[:, "direct"])
|
|
127
|
+
self.__loading = _create_summary(loadings, outer_model.model().loc[:, "loading"])
|
|
128
|
+
|
|
129
|
+
def weights(self) -> pd.DataFrame:
|
|
130
|
+
"""Outer weights calculated from bootstrap validation."""
|
|
131
|
+
return self.__weights
|
|
132
|
+
|
|
133
|
+
def r_squared(self) -> pd.DataFrame:
|
|
134
|
+
"""R squared for latent variables calculated from bootstrap validation."""
|
|
135
|
+
return self.__r_squared
|
|
136
|
+
|
|
137
|
+
def total_effects(self) -> pd.DataFrame:
|
|
138
|
+
"""Total effects for paths calculated from bootstrap validation."""
|
|
139
|
+
return self.__total_effects
|
|
140
|
+
|
|
141
|
+
def paths(self) -> pd.DataFrame:
|
|
142
|
+
"""Direct effects for paths calculated from bootstrap validation."""
|
|
143
|
+
return self.__paths[self.__paths["mean"] != 0]
|
|
144
|
+
|
|
145
|
+
def loading(self) -> pd.DataFrame:
|
|
146
|
+
"""Loadings of manifest variables calculated from bootstrap validation."""
|
|
147
|
+
return self.__loading
|
plspm/config.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
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 itertools as it
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import numpy.testing as npt
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
import plspm.util as util
|
|
25
|
+
from plspm.mode import Mode
|
|
26
|
+
from plspm.scale import Scale
|
|
27
|
+
from plspm.util import TopoSort
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Structure:
|
|
31
|
+
"""Specify relationships betweeen constructs
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Use this class to specify the relationships between constructs. It will generate a path matrix suitable for using in :class:`~.plspm.config.Config`.
|
|
35
|
+
"""
|
|
36
|
+
def __init__(self, path: pd.DataFrame = None):
|
|
37
|
+
self.__toposort = TopoSort()
|
|
38
|
+
if path is not None:
|
|
39
|
+
paths = [(path.columns[y], path.index[x]) for x, y in zip(*np.where(path.values == 1))]
|
|
40
|
+
for my_path in paths:
|
|
41
|
+
self.add_path([my_path[0]], [my_path[1]])
|
|
42
|
+
|
|
43
|
+
def add_path(self, source: list, target: list):
|
|
44
|
+
"""Specify a relationship between two sets of constructs.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
source: A list of antecedent constructs
|
|
48
|
+
target: A list of outcome constructs
|
|
49
|
+
"""
|
|
50
|
+
if len(source) != 1 and len(target) != 1:
|
|
51
|
+
raise ValueError("Either source or target must be a list containing a single entry")
|
|
52
|
+
if len(source) == 0 or len(target) == 0:
|
|
53
|
+
raise ValueError("Both source and target must contain at least one entry")
|
|
54
|
+
for element in it.product(source, target):
|
|
55
|
+
self.__toposort.append(element[0], element[1])
|
|
56
|
+
|
|
57
|
+
def path(self):
|
|
58
|
+
"""Get a path matrix for use in :class:`~plspm.Config`.
|
|
59
|
+
"""
|
|
60
|
+
index = self.__toposort.order()
|
|
61
|
+
path = pd.DataFrame(np.zeros((len(index), len(index)), int), columns=index, index=index)
|
|
62
|
+
for source, target in self.__toposort.elements():
|
|
63
|
+
path.at[target, source] = 1
|
|
64
|
+
return path
|
|
65
|
+
|
|
66
|
+
class MV:
|
|
67
|
+
"""Specify a manifest variable to use in the model.
|
|
68
|
+
|
|
69
|
+
You must create an instance of this class for every manifest variable when using :meth:`add_lv` to add latent variables to the model.
|
|
70
|
+
"""
|
|
71
|
+
def __init__(self, name: str, scale: Scale = None):
|
|
72
|
+
"""Specify a manifest variable to use in the model.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
name: The name of the manifest variable (must match the column name corresponding to this manifest variable in the dataset).
|
|
76
|
+
scale: If using nonmetric data, the measurement type of the variable (otherwise ``None``). Takes a value from the enum :class:`~plspm.scale.Scale`. Note that if you specify a scale for one variable, you must specify a scale for all variables (or specify a default scale to use in the constructor).
|
|
77
|
+
"""
|
|
78
|
+
self.__scale = scale
|
|
79
|
+
self.__name = name
|
|
80
|
+
|
|
81
|
+
def name(self):
|
|
82
|
+
"""Internal method that returns the name of the manifest variable"""
|
|
83
|
+
return self.__name
|
|
84
|
+
|
|
85
|
+
def scale(self):
|
|
86
|
+
"""Internal method that returns the scale of the manifest variable (if specified)"""
|
|
87
|
+
return self.__scale
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Config:
|
|
91
|
+
"""Specify the model you want to calculate with :class:`~.plspm.Plspm`
|
|
92
|
+
|
|
93
|
+
Create an instance of this class in order to specify the model you want to use :class:`~.plspm.Plspm` to calculate.
|
|
94
|
+
"""
|
|
95
|
+
def __init__(self, path: pd.DataFrame, scaled: bool = True, default_scale: Scale = None):
|
|
96
|
+
"""Specify the model you want to calculate with :class:`~.plspm.Plspm`:
|
|
97
|
+
|
|
98
|
+
Once you have created an instance of this class, add the relevant latent and manifest variables with :meth:`add_lv` or :meth:`add_lv_with_columns_named`
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
path: A matrix specifying the inner model. You can create this using :class:`~plspm.config.Structure`. This square lower triangular matrix specifies the paths between the latent variables (the inner model). The index and columns of this matrix must be the same, and must consist of the names of the latent variables. Cells should contain 1 if the variable in the column affects the variable in the row, 0 otherwise.
|
|
102
|
+
scaled: Whether manifest variables should be standardized. When ``True``, data is scaled to standardized values (mean 0 and variance 1). Only used when ``default_scale`` is set to ``None``.
|
|
103
|
+
default_scale: If your data is nonmetric, specify a default measurement type. Takes a value from the enum :class:`~plspm.scale.Scale`, or ``None`` (the default) if the data is metric and does not require scaling.
|
|
104
|
+
"""
|
|
105
|
+
self.__modes = {}
|
|
106
|
+
self.__mvs = {}
|
|
107
|
+
self.__hoc = {}
|
|
108
|
+
self.__dummies = {}
|
|
109
|
+
self.__mv_scales = {}
|
|
110
|
+
self.__scaled = scaled
|
|
111
|
+
self.__metric = True
|
|
112
|
+
self.__default_scale = default_scale
|
|
113
|
+
self.__missing = False
|
|
114
|
+
if not isinstance(path, pd.DataFrame):
|
|
115
|
+
raise TypeError("Path argument must be a Pandas DataFrame")
|
|
116
|
+
path_shape = path.shape
|
|
117
|
+
if path_shape[0] != path_shape[1]:
|
|
118
|
+
raise ValueError("Path argument must be a square matrix")
|
|
119
|
+
try:
|
|
120
|
+
npt.assert_array_equal(path, np.tril(path))
|
|
121
|
+
except:
|
|
122
|
+
raise ValueError("Path argument must be a lower triangular matrix")
|
|
123
|
+
if not path.isin([0, 1]).all(axis=None):
|
|
124
|
+
raise ValueError("Path matrix element values may only be in [0, 1]")
|
|
125
|
+
try:
|
|
126
|
+
npt.assert_array_equal(path.columns.values, path.index.values)
|
|
127
|
+
except:
|
|
128
|
+
raise ValueError("Path matrix must have matching row and column index names")
|
|
129
|
+
self.__path = path
|
|
130
|
+
|
|
131
|
+
def clone(self):
|
|
132
|
+
my_clone = Config(self.__path, self.__scaled, self.__default_scale)
|
|
133
|
+
my_clone.__modes = self.__modes.copy()
|
|
134
|
+
my_clone.__mvs = self.__mvs.copy()
|
|
135
|
+
my_clone.__hoc = self.__hoc.copy()
|
|
136
|
+
my_clone.__dummies = self.__dummies.copy()
|
|
137
|
+
my_clone.__mv_scales = self.__mv_scales.copy()
|
|
138
|
+
my_clone.__metric = self.__metric
|
|
139
|
+
my_clone.__missing = self.__missing
|
|
140
|
+
return my_clone
|
|
141
|
+
|
|
142
|
+
def path(self):
|
|
143
|
+
"""Internal method that returns the matrix of paths provided in the constructor."""
|
|
144
|
+
return self.__path
|
|
145
|
+
|
|
146
|
+
def odm(self, path: pd.DataFrame):
|
|
147
|
+
"""Internal method that returns the outer design matrix showing which manifest variables belong to which latent variables in the model."""
|
|
148
|
+
# Filter out LVs that aren't in path matrix
|
|
149
|
+
mvs = { key: self.__mvs[key] for key in list(path) }
|
|
150
|
+
return util.list_to_dummy(mvs)
|
|
151
|
+
|
|
152
|
+
def mv_index(self, lv, mv):
|
|
153
|
+
"""Internal method that returns the index of a manifest variable for a given latent variable."""
|
|
154
|
+
return self.__mvs[lv].index(mv)
|
|
155
|
+
|
|
156
|
+
def mvs(self, lv):
|
|
157
|
+
"""Internal method that returns the manifest variables belonging to a given latent variable."""
|
|
158
|
+
return self.__mvs[lv]
|
|
159
|
+
|
|
160
|
+
def hoc(self):
|
|
161
|
+
"""Internal method that returns a dictionary with the higher order constructs in the model as the key and the list of constituent LVs as the value."""
|
|
162
|
+
return self.__hoc
|
|
163
|
+
|
|
164
|
+
def mode(self, lv: str):
|
|
165
|
+
"""Internal method that returns the mode of a given latent variable."""
|
|
166
|
+
return self.__modes[lv]
|
|
167
|
+
|
|
168
|
+
def metric(self):
|
|
169
|
+
"""Internal method that returns whether we are using metric or nonmetric data."""
|
|
170
|
+
return self.__metric
|
|
171
|
+
|
|
172
|
+
def scaled(self):
|
|
173
|
+
"""Internal method that returns whether the user asked for the data to be scaled."""
|
|
174
|
+
return self.__scaled
|
|
175
|
+
|
|
176
|
+
def scale(self, mv: str):
|
|
177
|
+
"""Internal method that returns the scale for a given manifest variable."""
|
|
178
|
+
return self.__mv_scales[mv]
|
|
179
|
+
|
|
180
|
+
def dummies(self, mv: str):
|
|
181
|
+
"""Internal method that returns a dummy matrix which is used for handling ordinal or nominal data"""
|
|
182
|
+
return self.__dummies[mv]
|
|
183
|
+
|
|
184
|
+
def add_lv(self, lv_name: str, mode: Mode, *mvs: MV):
|
|
185
|
+
"""Add a latent variable and associated manifest variables to the model.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
lv_name: The name of the latent variable to add. Must match the name used in the columns / index of the Path matrix passed into the constructor.
|
|
189
|
+
mode: Whether the latent variable is reflective (mode A) or formative (mode B) with respect to its manifest variables. Takes a value from the enum :class:`~plspm.mode.Mode`
|
|
190
|
+
*mvs: A list of manifest variables that make up the latent variable. These must be instances of :class:`MV`
|
|
191
|
+
"""
|
|
192
|
+
assert mode in Mode
|
|
193
|
+
hoc_lvs = [item for lvs in self.__hoc.values() for item in lvs]
|
|
194
|
+
if lv_name not in self.__path and lv_name not in hoc_lvs:
|
|
195
|
+
raise ValueError("Latent variable " + lv_name + " is not listed in the outer model paths or higher order constructs.")
|
|
196
|
+
self.__modes[lv_name] = mode
|
|
197
|
+
self.__mvs[lv_name] = []
|
|
198
|
+
for mv in mvs:
|
|
199
|
+
if mv.name() in self.__mv_scales:
|
|
200
|
+
raise ValueError("You can only specify a column once. You can specify a higher order construct with `add_higher_order(...)`")
|
|
201
|
+
if mv.name() in list(self.__path):
|
|
202
|
+
raise ValueError("You cannot specify MVs with the same name as LVs.")
|
|
203
|
+
self.__mvs[lv_name].append(mv.name())
|
|
204
|
+
scale = self.__default_scale if mv.scale() is None else mv.scale()
|
|
205
|
+
self.__mv_scales[mv.name()] = scale
|
|
206
|
+
if scale is not None:
|
|
207
|
+
self.__metric = False
|
|
208
|
+
|
|
209
|
+
def remove_lv(self, lv_name:str):
|
|
210
|
+
"""Remove a latent variable and associated manifest variables from the model.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
lv_name: The name of the latent variable to remove.
|
|
214
|
+
"""
|
|
215
|
+
self.__mvs.pop(lv_name)
|
|
216
|
+
self.__modes.pop(lv_name)
|
|
217
|
+
|
|
218
|
+
def add_higher_order(self, hoc_name: str, mode: Mode, lvs: list):
|
|
219
|
+
"""Add a higher order construct to the model.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
name: The name of the higher order constuct to add. Must match the name used in the columns / index of the Path matrix passed into the constructor.
|
|
223
|
+
mode: Whether to use correlation weights (mode A) or regression weights (mode B) with respect to the relationship between the second order construct and its component first order constructs.
|
|
224
|
+
method: Estimation method to use
|
|
225
|
+
lvs: A list of the first order constructs that comprise this second order construct
|
|
226
|
+
"""
|
|
227
|
+
# TODO: Warn if centroid scheme is used with HOC.
|
|
228
|
+
assert mode in Mode
|
|
229
|
+
if hoc_name not in self.__path:
|
|
230
|
+
raise ValueError("Path matrix does not contain reference to higher order construct " + hoc_name)
|
|
231
|
+
self.__modes[hoc_name] = mode
|
|
232
|
+
self.__hoc[hoc_name] = lvs
|
|
233
|
+
|
|
234
|
+
def add_lv_with_columns_named(self, lv_name: str, mode: Mode, data: pd.DataFrame, col_name_starts_with: str,
|
|
235
|
+
default_scale: Scale = None):
|
|
236
|
+
"""Add a latent variable and associated manifest variables to the model.
|
|
237
|
+
|
|
238
|
+
This is a convenience method that can be used if the names of the columns in the dataset corresponding to the manifest variables all share a common prefix, and are all the same :class:`.Scale` (if nonmetric).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
lv_name: The name of the latent variable to add. Must match the name used in the columns / index of the Path matrix passed into the constructor.
|
|
242
|
+
mode: Whether the latent variable is reflective (mode A) or formative (mode B) with respect to its manifest variables. Takes a value from the enum :class:`~plspm.mode.Mode`
|
|
243
|
+
data: The dataset that will be passed into :class:`.Plspm`
|
|
244
|
+
col_name_starts_with: The prefix for the column names in the dataset corresponding to the manifest variables. For example, if the columns are named ``var1``, ``var2``, ``var3``, use ``var``
|
|
245
|
+
default_scale: If the data is nonmetric, the measurement type of the manifest variables. Note that you can only use this method if all the manifest variables are the same type of measurement, otherwise use :meth:`add_lv`. Takes a value from the enum :class:`~plspm.scale.Scale`. ``None`` (the default) if the data is metric.
|
|
246
|
+
"""
|
|
247
|
+
names = filter(lambda x: x.startswith(col_name_starts_with), list(data))
|
|
248
|
+
mvs = list(map(lambda mv: MV(mv, default_scale), names))
|
|
249
|
+
if len(mvs) == 0:
|
|
250
|
+
raise ValueError("No columns were found in the data starting with " + col_name_starts_with)
|
|
251
|
+
self.add_lv(lv_name, mode, *mvs)
|
|
252
|
+
|
|
253
|
+
def filter(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
254
|
+
"""Internal method that removes columns from the dataset that are not specified in the model, and rows where all MVs for an LV are unknown.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
data: The dataset to filter
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
The dataset with columns not specified in the columns removed.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
ValueError: if the dataset is missing any columns with names that were specified as manifest variables in the model, or if there are any non-numeric values in the dataset.
|
|
264
|
+
"""
|
|
265
|
+
hoc_lvs = [item for lvs in self.__hoc.values() for item in lvs]
|
|
266
|
+
path_lvs = filter(lambda i: i not in self.__hoc.keys(), list(self.path()) + hoc_lvs)
|
|
267
|
+
if set(self.__mvs.keys()) != set(path_lvs):
|
|
268
|
+
raise ValueError(
|
|
269
|
+
"The Path matrix supplied does not specify the same latent variables as you added when configuring manifest variables." +
|
|
270
|
+
" Path: " + ", ".join(path_lvs) + " LVs: " + ", ".join(set(self.__mvs.keys())))
|
|
271
|
+
if not set(self.__mv_scales.keys()).issubset(set(data)):
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"The following manifest variables you configured are not present in the data set: " + ", ".join(
|
|
274
|
+
set(self.__mv_scales.keys()).difference(set(data))))
|
|
275
|
+
data = data[list(self.__mv_scales.keys())]
|
|
276
|
+
if False in data.apply(lambda x: np.issubdtype(x.dtype, np.number)).values:
|
|
277
|
+
raise ValueError(
|
|
278
|
+
"Data must only contain numeric values. Please convert any categorical data into numerical values.")
|
|
279
|
+
self.__missing = data.isnull().values.any()
|
|
280
|
+
# Delete any rows which has all MVs for an LV as NaN
|
|
281
|
+
if self.__missing:
|
|
282
|
+
mv_grouped_by_lv = {}
|
|
283
|
+
rows_to_delete = set()
|
|
284
|
+
for i, lv in enumerate(list(self.path())):
|
|
285
|
+
mvs = self.mvs(lv)
|
|
286
|
+
mv_grouped_by_lv[lv] = data.filter(mvs).values.astype(np.float64)
|
|
287
|
+
for j in range(len(data.index)):
|
|
288
|
+
if np.count_nonzero(~np.isnan(mv_grouped_by_lv[lv][j, :])) == 0:
|
|
289
|
+
rows_to_delete.add(j)
|
|
290
|
+
data = data.drop(data.index[list(rows_to_delete)])
|
|
291
|
+
return data
|
|
292
|
+
|
|
293
|
+
def treat(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
294
|
+
"""Internal method that treats the data (including scaling, normalizing, standardizing and rankifying, where appropriate)
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
data: The dataset to treat.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
The treated dataset.
|
|
301
|
+
|
|
302
|
+
Raises:
|
|
303
|
+
TypeError: if you have specified a scale for some but not all manifest variables. Specifying a scale for any MV tells Plspm that you are using nonmetric data, which means you must specify a scale for all MVs (or specify a default scale in the constructor).
|
|
304
|
+
"""
|
|
305
|
+
if self.__metric:
|
|
306
|
+
metric_data = util.impute(data) if self.__missing else data
|
|
307
|
+
if self.__scaled:
|
|
308
|
+
scale_values = metric_data.stack().std() * np.sqrt((metric_data.shape[0] - 1) / metric_data.shape[0])
|
|
309
|
+
return util.treat(metric_data, scale_values=scale_values)
|
|
310
|
+
else:
|
|
311
|
+
return util.treat(metric_data, scale=False)
|
|
312
|
+
else:
|
|
313
|
+
if None in self.__mv_scales.values():
|
|
314
|
+
raise TypeError("If you supply a scale for any MV, you must either supply a scale for all of them or specify a default scale.")
|
|
315
|
+
if set(self.__mv_scales.values()) == {Scale.RAW}:
|
|
316
|
+
self.__scaled = False
|
|
317
|
+
if set(self.__mv_scales.values()) == {Scale.RAW, Scale.NUM}:
|
|
318
|
+
self.__scaled = True
|
|
319
|
+
self.__mv_scales = dict.fromkeys(self.__mv_scales, Scale.NUM)
|
|
320
|
+
data = util.treat(data) / np.sqrt((data.shape[0] - 1) / data.shape[0])
|
|
321
|
+
for mv in self.__mv_scales:
|
|
322
|
+
if self.__mv_scales[mv] in [Scale.ORD, Scale.NOM]:
|
|
323
|
+
data.loc[:, mv] = util.rank(data.loc[:, mv])
|
|
324
|
+
self.__dummies[mv] = util.dummy(data.loc[:, mv]).values
|
|
325
|
+
return data
|
plspm/estimator.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
import plspm.config as c
|
|
22
|
+
from plspm.scale import Scale
|
|
23
|
+
from plspm.weights import WeightsCalculatorFactory
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Estimator:
|
|
27
|
+
"""Internal class that estimates the model. Designed to be threadsafe."""
|
|
28
|
+
def __init__(self, config: c.Config):
|
|
29
|
+
self.__hoc_path_first_stage = self.hoc_path_first_stage(config)
|
|
30
|
+
|
|
31
|
+
def estimate(self, calculator: WeightsCalculatorFactory, data: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
32
|
+
# Make sure we are threadsafe
|
|
33
|
+
calculator = calculator.clone()
|
|
34
|
+
config = calculator.config()
|
|
35
|
+
treated_data = config.treat(data)
|
|
36
|
+
|
|
37
|
+
hocs = config.hoc()
|
|
38
|
+
if hocs is not None:
|
|
39
|
+
path = self.__hoc_path_first_stage
|
|
40
|
+
|
|
41
|
+
final_data, scores, weights = calculator.calculate(treated_data, path)
|
|
42
|
+
|
|
43
|
+
# If we have higher order constructs, re-estimate the model using the scores of the constituent LVs of the HOC
|
|
44
|
+
# generated by the first round of estimation as the HOC's MVs.
|
|
45
|
+
if hocs is not None:
|
|
46
|
+
scale = None if config.metric() else Scale.NUM
|
|
47
|
+
for hoc in hocs:
|
|
48
|
+
new_mvs = []
|
|
49
|
+
for lv in hocs[hoc]:
|
|
50
|
+
mv_new = lv
|
|
51
|
+
treated_data[mv_new] = scores[lv]
|
|
52
|
+
new_mvs.append(c.MV(mv_new, scale))
|
|
53
|
+
config.add_lv(hoc, config.mode(hoc), *new_mvs)
|
|
54
|
+
final_data, scores, weights = calculator.calculate(treated_data, config.path())
|
|
55
|
+
self.__config = config
|
|
56
|
+
|
|
57
|
+
return final_data, scores, weights
|
|
58
|
+
|
|
59
|
+
def config(self):
|
|
60
|
+
return self.__config
|
|
61
|
+
|
|
62
|
+
def hoc_path_first_stage(self, config: c.Config) -> pd.DataFrame:
|
|
63
|
+
# For first pass, for HOCs we'll create paths from each and for each exogenous LV to the HOC's constituent LVs,
|
|
64
|
+
# and from each consituent LV to the endogenous LVs.
|
|
65
|
+
path = config.path()
|
|
66
|
+
for hoc, lvs in config.hoc().items():
|
|
67
|
+
structure = c.Structure(path)
|
|
68
|
+
exogenous = path.loc[hoc]
|
|
69
|
+
endogenous = path.loc[:, hoc]
|
|
70
|
+
# structure.add_path(lvs, [hoc])
|
|
71
|
+
for lv in list(exogenous[exogenous == 1].index):
|
|
72
|
+
structure.add_path([lv], lvs)
|
|
73
|
+
for lv in list(endogenous[endogenous == 1].index):
|
|
74
|
+
structure.add_path(lvs, [lv])
|
|
75
|
+
path = structure.path().drop(hoc).drop(hoc, axis=1)
|
|
76
|
+
return path
|