PyProcessMacro 2.0.0__py3-none-any.whl → 2.1.0__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.
@@ -7,4 +7,4 @@ from .utils import ConvergenceError
7
7
 
8
8
  __all__ = ["Process", "ConvergenceError"]
9
9
 
10
- __version__ = "2.0.0"
10
+ __version__ = "2.1.0"
@@ -0,0 +1,242 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Vectorized bootstrap of the outcome and mediator equations (#68).
4
+
5
+ Resample indices are still drawn one sample at a time from the same generator as before, so the random
6
+ stream is identical to the sequential implementation; only the estimation is batched. Chunks of resamples
7
+ are fitted with stacked linear algebra, and a chunk that hits a singular system falls back to fitting its
8
+ samples one by one so that failures are detected per sample exactly as before.
9
+ """
10
+ import numpy as np
11
+ from numpy.linalg import LinAlgError
12
+
13
+ from .utils import ConvergenceError, bootstrap_sampler, fast_OLS, fast_optimize
14
+
15
+ # Upper bound on the number of floating-point values held by one chunk of resampled data.
16
+ CHUNK_ELEMENTS = 10_000_000
17
+
18
+
19
+ class BootstrapSpec:
20
+ """Which columns of the data array are which, and how the outcome equation is estimated."""
21
+
22
+ def __init__(self, ind_y, exog_inds_y, inds_m, exog_inds_m, logit, max_iter, tolerance):
23
+ self.ind_y = ind_y
24
+ self.exog_inds_y = list(exog_inds_y)
25
+ self.inds_m = list(inds_m)
26
+ self.exog_inds_m = list(exog_inds_m)
27
+ self.logit = bool(logit)
28
+ self.max_iter = max_iter
29
+ self.tolerance = tolerance
30
+
31
+
32
+ def bootstrap_parameters(data, spec, n_boots, seed, chunk_size=None, sd_inds=None):
33
+ """
34
+ Estimate the outcome and mediator equations on n_boots resamples of the data.
35
+
36
+ :param data: (n_obs x n_cols) array of the analysis data
37
+ :param spec: BootstrapSpec
38
+ :param n_boots: number of successful resamples wanted
39
+ :param seed: seed of the resampler (None for fresh entropy)
40
+ :param chunk_size: resamples fitted per batch; None picks one from the data size
41
+ :param sd_inds: columns whose standard deviation is wanted for every successful resample (#70)
42
+ :return: (betas_y, betas_m, n_fail, sds): (n_boots x k_y) array, (n_meds x n_boots x k_m) array, the
43
+ number of resamples discarded because an equation could not be estimated on them, and the
44
+ (n_boots x len(sd_inds)) array of standard deviations, or None.
45
+ """
46
+ data = np.asarray(data, dtype=float)
47
+ n_obs, n_cols = data.shape
48
+ if chunk_size is None:
49
+ chunk_size = int(max(1, min(n_boots, CHUNK_ELEMENTS // max(1, n_obs * n_cols))))
50
+ sampler = bootstrap_sampler(n_obs, seed)
51
+ k_y, k_m, n_meds = len(spec.exog_inds_y), len(spec.exog_inds_m), len(spec.inds_m)
52
+ betas_y = np.empty((n_boots, k_y))
53
+ betas_m = np.empty((n_meds, n_boots, k_m))
54
+ sds = None if sd_inds is None else np.empty((n_boots, len(sd_inds)))
55
+ filled, n_fail, max_failures = 0, 0, n_boots
56
+ while filled < n_boots:
57
+ count = min(chunk_size, n_boots - filled)
58
+ indices = np.stack([next(sampler) for _ in range(count)]) # one draw per sample: same stream as before
59
+ chunk = data[indices]
60
+ chunk_y, chunk_m, failed = _fit_chunk(chunk, spec)
61
+ ok = ~failed
62
+ n_ok = int(ok.sum())
63
+ betas_y[filled:filled + n_ok] = chunk_y[ok]
64
+ betas_m[:, filled:filled + n_ok] = chunk_m[:, ok]
65
+ if sd_inds is not None:
66
+ sds[filled:filled + n_ok] = chunk[ok][:, :, sd_inds].std(axis=1, ddof=1)
67
+ filled += n_ok
68
+ n_fail += int(failed.sum())
69
+ if n_fail > max_failures:
70
+ raise RuntimeError(FAILURE_MESSAGE.format(n_fail=n_fail, n_boots=n_boots))
71
+ return betas_y, betas_m, n_fail, sds
72
+
73
+
74
+ def _fit_chunk(chunk, spec):
75
+ """
76
+ Fit every resample of a chunk. Returns (betas_y (c x k_y), betas_m (n_meds x c x k_m), failed (c,)).
77
+ A singular system anywhere in the batch sends the whole chunk to the one-by-one path.
78
+ """
79
+ y = chunk[:, :, spec.ind_y]
80
+ exog_y = chunk[:, :, spec.exog_inds_y]
81
+ exog_m = chunk[:, :, spec.exog_inds_m]
82
+ endog_m = chunk[:, :, spec.inds_m]
83
+ try:
84
+ if spec.logit:
85
+ betas_y, failed = _batch_logit(y, exog_y, spec.max_iter, spec.tolerance)
86
+ else:
87
+ betas_y = _batch_ols(y[..., None], exog_y)[..., 0]
88
+ failed = ~np.isfinite(betas_y).all(axis=1)
89
+ betas_m = _batch_ols(endog_m, exog_m) # (c, k_m, n_meds)
90
+ except LinAlgError:
91
+ return _fit_one_by_one(chunk, spec)
92
+ failed = failed | ~np.isfinite(betas_m).all(axis=(1, 2))
93
+ return betas_y, np.transpose(betas_m, (2, 0, 1)), failed
94
+
95
+
96
+ def _fit_one_by_one(chunk, spec):
97
+ """The sequential path, used when a batch contains a singular system: same estimators as 1.x."""
98
+ count = chunk.shape[0]
99
+ betas_y = np.zeros((count, len(spec.exog_inds_y)))
100
+ betas_m = np.zeros((len(spec.inds_m), count, len(spec.exog_inds_m)))
101
+ failed = np.zeros(count, dtype=bool)
102
+ for i in range(count):
103
+ sample = chunk[i]
104
+ try:
105
+ if spec.logit:
106
+ betas_y[i] = fast_optimize(
107
+ sample[:, spec.ind_y], sample[:, spec.exog_inds_y], n_obs=sample.shape[0],
108
+ n_vars=len(spec.exog_inds_y), max_iter=spec.max_iter, tolerance=spec.tolerance,
109
+ )
110
+ else:
111
+ betas_y[i] = fast_OLS(sample[:, spec.ind_y], sample[:, spec.exog_inds_y])
112
+ for j, ind in enumerate(spec.inds_m):
113
+ betas_m[j, i] = fast_OLS(sample[:, ind], sample[:, spec.exog_inds_m])
114
+ except (LinAlgError, ConvergenceError):
115
+ failed[i] = True
116
+ return betas_y, betas_m, failed
117
+
118
+
119
+ def _batch_ols(endog, exog):
120
+ """(X'X)^-1 X'Y for a batch: endog (c x n x r), exog (c x n x k) -> (c x k x r)."""
121
+ xtx = np.einsum("cnk,cnj->ckj", exog, exog)
122
+ xty = np.einsum("cnk,cnr->ckr", exog, endog)
123
+ return np.linalg.solve(xtx, xty)
124
+
125
+
126
+ def _logit_cdf(z):
127
+ out = np.empty_like(z)
128
+ positive = z > 0
129
+ out[positive] = 1 / (1 + np.exp(-z[positive]))
130
+ expz = np.exp(z[~positive])
131
+ out[~positive] = expz / (1 + expz)
132
+ return out
133
+
134
+
135
+ def _batch_logit(endog, exog, max_iter, tolerance):
136
+ """
137
+ Newton-Raphson for a batch of logistic regressions: endog (c x n), exog (c x n x k) -> (c x k), failed (c,).
138
+ Samples leave the active set when they converge; a sample whose parameters stop being finite, or that has
139
+ not converged after max_iter updates, is marked failed.
140
+ """
141
+ count, n_obs, k = exog.shape
142
+ params = np.zeros((count, k))
143
+ failed = np.zeros(count, dtype=bool)
144
+ active = np.ones(count, dtype=bool)
145
+ for _ in range(max_iter):
146
+ idx = np.flatnonzero(active)
147
+ if idx.size == 0:
148
+ break
149
+ x, y, p = exog[idx], endog[idx], params[idx]
150
+ fitted = _logit_cdf(np.einsum("cnk,ck->cn", x, p))
151
+ score = np.einsum("cn,cnk->ck", y - fitted, x) / n_obs
152
+ hessian = -np.einsum("cnk,cn,cnj->ckj", x, fitted * (1 - fitted), x) / n_obs
153
+ new = p - np.linalg.solve(hessian, score[..., None])[..., 0]
154
+ finite = np.isfinite(new).all(axis=1)
155
+ converged = finite & (np.abs(new - p).max(axis=1) <= tolerance)
156
+ params[idx] = np.where(finite[:, None], new, p)
157
+ failed[idx[~finite]] = True
158
+ active[idx[~finite | converged]] = False
159
+ failed |= active # still active after max_iter updates: not converged
160
+ # Saturated fits: every outcome predicted exactly means separation, not convergence.
161
+ fitted = _logit_cdf(np.einsum("cnk,ck->cn", exog, params))
162
+ failed |= np.abs(endog - fitted).max(axis=1) < 1e-8
163
+ return params, failed
164
+
165
+
166
+ FAILURE_MESSAGE = (
167
+ "{n_fail} bootstrap samples failed to estimate before {n_boots} succeeded. The model is probably not "
168
+ "estimable on resamples of this data (check for separation, collinearity, or a very small sample)."
169
+ )
170
+
171
+
172
+ def bootstrap_equations(data, equations, n_boots, seed, max_iter=10000, tolerance=1e-10, chunk_size=None,
173
+ sd_inds=None):
174
+ """
175
+ Estimate several equations, each with its own design matrix, on n_boots resamples (serial mediation).
176
+
177
+ :param equations: list of (endog_ind, exog_inds, logit) triples
178
+ :param sd_inds: columns whose standard deviation is wanted for every successful resample (#70)
179
+ :return: (list of (n_boots x k_i) arrays, one per equation, n_fail, sds or None)
180
+ """
181
+ data = np.asarray(data, dtype=float)
182
+ n_obs, n_cols = data.shape
183
+ if chunk_size is None:
184
+ chunk_size = int(max(1, min(n_boots, CHUNK_ELEMENTS // max(1, n_obs * n_cols))))
185
+ sampler = bootstrap_sampler(n_obs, seed)
186
+ betas = [np.empty((n_boots, len(exog_inds))) for _, exog_inds, _ in equations]
187
+ sds = None if sd_inds is None else np.empty((n_boots, len(sd_inds)))
188
+ filled, n_fail, max_failures = 0, 0, n_boots
189
+ while filled < n_boots:
190
+ count = min(chunk_size, n_boots - filled)
191
+ indices = np.stack([next(sampler) for _ in range(count)])
192
+ chunk = data[indices]
193
+ chunk_betas, failed = _fit_equations_chunk(chunk, equations, max_iter, tolerance)
194
+ ok = ~failed
195
+ n_ok = int(ok.sum())
196
+ for store, estimates in zip(betas, chunk_betas):
197
+ store[filled:filled + n_ok] = estimates[ok]
198
+ if sd_inds is not None:
199
+ sds[filled:filled + n_ok] = chunk[ok][:, :, sd_inds].std(axis=1, ddof=1)
200
+ filled += n_ok
201
+ n_fail += int(failed.sum())
202
+ if n_fail > max_failures:
203
+ raise RuntimeError(FAILURE_MESSAGE.format(n_fail=n_fail, n_boots=n_boots))
204
+ return betas, n_fail, sds
205
+
206
+
207
+ def _fit_equations_chunk(chunk, equations, max_iter, tolerance):
208
+ failed = np.zeros(chunk.shape[0], dtype=bool)
209
+ estimates = []
210
+ try:
211
+ for endog_ind, exog_inds, logit in equations:
212
+ endog, exog = chunk[:, :, endog_ind], chunk[:, :, exog_inds]
213
+ if logit:
214
+ betas, bad = _batch_logit(endog, exog, max_iter, tolerance)
215
+ else:
216
+ betas = _batch_ols(endog[..., None], exog)[..., 0]
217
+ bad = ~np.isfinite(betas).all(axis=1)
218
+ estimates.append(betas)
219
+ failed |= bad
220
+ except LinAlgError:
221
+ return _fit_equations_one_by_one(chunk, equations, max_iter, tolerance)
222
+ return estimates, failed
223
+
224
+
225
+ def _fit_equations_one_by_one(chunk, equations, max_iter, tolerance):
226
+ count = chunk.shape[0]
227
+ estimates = [np.zeros((count, len(exog_inds))) for _, exog_inds, _ in equations]
228
+ failed = np.zeros(count, dtype=bool)
229
+ for i in range(count):
230
+ sample = chunk[i]
231
+ try:
232
+ for store, (endog_ind, exog_inds, logit) in zip(estimates, equations):
233
+ if logit:
234
+ store[i] = fast_optimize(
235
+ sample[:, endog_ind], sample[:, exog_inds], n_obs=sample.shape[0],
236
+ n_vars=len(exog_inds), max_iter=max_iter, tolerance=tolerance,
237
+ )
238
+ else:
239
+ store[i] = fast_OLS(sample[:, endog_ind], sample[:, exog_inds])
240
+ except (LinAlgError, ConvergenceError):
241
+ failed[i] = True
242
+ return estimates, failed
@@ -0,0 +1,77 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Standardized indirect effects (#70), the `effsize` option.
4
+
5
+ For an unmoderated indirect path and a continuous outcome, PROCESS reports the partially standardized
6
+ indirect effect (the indirect effect divided by the standard deviation of Y) and the completely
7
+ standardized indirect effect (further multiplied by the standard deviation of X). Both are bootstrapped
8
+ by standardizing within each resample with that resample's standard deviations, as PROCESS does.
9
+ """
10
+ from functools import partial
11
+
12
+ import numpy as np
13
+
14
+ from .models import _summary_table
15
+ from .utils import bias_corrected_ci, percentile_ci
16
+
17
+ KINDS = {"ps": "Partially standardized", "cs": "Completely standardized"}
18
+ TABLE_COLUMNS = ["Effect", "Boot SE", "BootLLCI", "BootULCI"]
19
+
20
+
21
+ def _display(label):
22
+ """Row label as the indirect-effect table prints it."""
23
+ return "TOTAL" if label == "total" else label
24
+
25
+
26
+ def standardized_effects(model):
27
+ """
28
+ :param model: a ParallelMediationModel or SerialMediationModel with `_raw_indirect_draws()` and `_boot_sds`
29
+ :return: {"ps": {...}, "cs": {...}} where each value holds "labels" and the arrays "effect", "se",
30
+ "llci", "ulci", one entry per row (the total if requested, then one per mediator or path)
31
+ """
32
+ labels, effects, draws = model._raw_indirect_draws()
33
+ ind_x, ind_y = model._symb_to_ind["x"], model._ind_y
34
+ sd_x, sd_y = model._data[:, ind_x].std(ddof=1), model._data[:, ind_y].std(ddof=1)
35
+ boot_sd_x, boot_sd_y = model._boot_sds[:, 0], model._boot_sds[:, 1]
36
+ scales = {"ps": (1 / sd_y, 1 / boot_sd_y), "cs": (sd_x / sd_y, boot_sd_x / boot_sd_y)}
37
+ conf = model._options["conf"]
38
+ interval = percentile_ci if model._options["percent"] else None
39
+ out = {}
40
+ for kind, (scale, boot_scale) in scales.items():
41
+ estimate = effects * scale
42
+ boot = draws * boot_scale
43
+ rows = {"labels": list(labels), "effect": estimate, "se": boot.std(axis=1, ddof=1)}
44
+ bounds = np.array([
45
+ interval(boot[i], conf) if interval else bias_corrected_ci(estimate[i], boot[i], conf)
46
+ for i in range(len(labels))
47
+ ])
48
+ rows["llci"], rows["ulci"] = bounds[:, 0], bounds[:, 1]
49
+ out[kind] = rows
50
+ return out
51
+
52
+
53
+ def effect_size_table(model):
54
+ """One table with a Standardization column: partial rows first, then complete rows."""
55
+ results = standardized_effects(model)
56
+ levels, stats = [], []
57
+ for kind in ("ps", "cs"):
58
+ rows = results[kind]
59
+ for i, label in enumerate(rows["labels"]):
60
+ levels.append([_display(label), KINDS[kind].split()[0].lower()])
61
+ stats.append([rows["effect"][i], rows["se"][i], rows["llci"][i], rows["ulci"][i]])
62
+ return _summary_table(levels, ["", "Standardization"], np.array(stats), TABLE_COLUMNS)
63
+
64
+
65
+ def effect_size_text(model, float_format):
66
+ """The two sections PROCESS prints, one per kind of standardization."""
67
+ stv = model._symb_to_var
68
+ results = standardized_effects(model)
69
+ text = ""
70
+ for kind in ("ps", "cs"):
71
+ rows = results[kind]
72
+ stats = np.array([rows["effect"], rows["se"], rows["llci"], rows["ulci"]]).T
73
+ table = _summary_table([[_display(label)] for label in rows["labels"]], [""], stats, TABLE_COLUMNS)
74
+ text += "{kind} indirect effect(s) of {x} on {y}:\n\n{table}\n\n".format(
75
+ kind=KINDS[kind], x=stv["x"], y=stv["y"], table=table.to_string(float_format=float_format)
76
+ )
77
+ return text