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.
- pyprocessmacro/__init__.py +1 -1
- pyprocessmacro/bootstrap.py +242 -0
- pyprocessmacro/effsize.py +77 -0
- pyprocessmacro/models.py +131 -108
- pyprocessmacro/process.py +119 -44
- pyprocessmacro/serial.py +218 -0
- pyprocessmacro/tidy.py +219 -0
- pyprocessmacro/utils.py +3 -0
- {pyprocessmacro-2.0.0.dist-info → pyprocessmacro-2.1.0.dist-info}/METADATA +87 -4
- pyprocessmacro-2.1.0.dist-info/RECORD +13 -0
- pyprocessmacro-2.0.0.dist-info/RECORD +0 -9
- {pyprocessmacro-2.0.0.dist-info → pyprocessmacro-2.1.0.dist-info}/WHEEL +0 -0
- {pyprocessmacro-2.0.0.dist-info → pyprocessmacro-2.1.0.dist-info}/licenses/LICENSE.txt +0 -0
- {pyprocessmacro-2.0.0.dist-info → pyprocessmacro-2.1.0.dist-info}/top_level.txt +0 -0
pyprocessmacro/__init__.py
CHANGED
|
@@ -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
|