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/__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