py-flexplot 0.8.2__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.
pyflexplot/bluepill.py ADDED
@@ -0,0 +1,461 @@
1
+ """
2
+ bluepill: simulated-dataset utilities for py-flexplot.
3
+
4
+ This module is a Python port of Dustin Fife's ``bluepill`` R package
5
+ (https://github.com/dustinfife/bluepill -- "An R package for creating
6
+ simulated dataset."). It exposes two ideas from that package:
7
+
8
+ * ``estimate_sd(mean, min, max, num_sds=3)`` -- back out a plausible
9
+ standard deviation from a known mean and a known min/max range. Useful
10
+ when designing a simulation and you have a target distribution shape
11
+ but no variance handy.
12
+
13
+ * ``mixed_model(...)`` -- generate a synthetic data frame with the
14
+ structural properties of a mixed-effects model: fixed and random
15
+ effects per predictor, configurable cluster sizes, residual noise,
16
+ interactions, and polynomial terms. Categorical variables are
17
+ supported.
18
+
19
+ The function is invaluable for teaching examples (Titanic-style demos),
20
+ the power-analysis work py-flexplot is often used for, and for stress-
21
+ testing the visualization code with data whose ground truth is known.
22
+
23
+ Notes on the port
24
+ -----------------
25
+ The R source has several long-standing typos (e.g. a ``prediction_matrix``
26
+ variable that is never assigned, broken test expectations in
27
+ ``expect_error`` comments). This Python port follows the same conceptual
28
+ design but has those typos fixed. Behaviour the R docs describe but the R
29
+ code does not deliver (e.g. multi-class handling) is implemented here.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any, Dict, Optional, Sequence, Tuple, Union
35
+
36
+ import numpy as np
37
+ import pandas as pd
38
+
39
+ __all__ = [
40
+ "estimate_sd",
41
+ "mixed_model",
42
+ ]
43
+
44
+
45
+ # A *var* entry is either:
46
+ # * a 3-tuple (mean, sd, digits) for a continuous variable, or
47
+ # * a sequence of strings for a categorical variable.
48
+ # The final entry in ``vars`` is the cluster id and must be categorical.
49
+ VarSpec = Union[Tuple[float, float, int], Sequence[str]]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # estimate_sd
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def estimate_sd(
57
+ mean: float,
58
+ min_val: float,
59
+ max_val: float,
60
+ num_sds: float = 3,
61
+ ) -> float:
62
+ """Estimate a standard deviation from a mean and a known min/max range.
63
+
64
+ Parameters
65
+ ----------
66
+ mean
67
+ The target mean of the distribution.
68
+ min_val, max_val
69
+ Known extreme values the distribution should comfortably reach.
70
+ (Named ``min_val`` / ``max_val`` rather than ``min`` / ``max`` so
71
+ they don't shadow the Python built-ins.)
72
+ num_sds
73
+ How many standard deviations wide the range should be. The R
74
+ default is 3 -- i.e. the range covers ``+/- 3 SD`` around the mean.
75
+
76
+ Returns
77
+ -------
78
+ float
79
+ Estimated standard deviation. Larger ``num_sds`` yields a smaller
80
+ SD (more of the range is "inside" the distribution).
81
+
82
+ Raises
83
+ ------
84
+ ValueError
85
+ If ``max_val < mean`` or ``min_val > mean`` or ``num_sds <= 0``.
86
+ """
87
+ if max_val < mean:
88
+ raise ValueError(
89
+ f"max ({max_val}) must be >= mean ({mean}) for estimate_sd"
90
+ )
91
+ if min_val > mean:
92
+ raise ValueError(
93
+ f"min ({min_val}) must be <= mean ({mean}) for estimate_sd"
94
+ )
95
+ if num_sds <= 0:
96
+ raise ValueError(f"num_sds must be positive, got {num_sds}")
97
+
98
+ # Distance from mean to whichever extreme is closer.
99
+ extent = min(mean - min_val, max_val - mean)
100
+ return extent / num_sds
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # mixed_model
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def mixed_model(
108
+ fixed: Sequence[float],
109
+ random: Sequence[float],
110
+ sigma: float,
111
+ clusters: int,
112
+ n_per: Sequence[float],
113
+ vars: Dict[str, VarSpec],
114
+ interactions: Optional[Dict[str, Sequence]] = None,
115
+ polynomials: Optional[Dict[str, Sequence]] = None,
116
+ seed: Optional[int] = None,
117
+ ) -> pd.DataFrame:
118
+ """Generate a synthetic mixed-model data frame.
119
+
120
+ Conceptual layout, modelled on the R package's design:
121
+
122
+ * ``fixed`` and ``random`` are length-``n+1`` vectors where ``fixed[0]``
123
+ is the intercept coefficient and ``random[0]`` is the intercept's
124
+ random-effect SD. ``fixed[1:]`` and ``random[1:]`` correspond to
125
+ the predictors declared in ``vars`` (excluding the cluster id).
126
+ * Each cluster draws one coefficient vector from
127
+ ``N(fixed, random)`` and replicates it across that cluster's
128
+ observations. If ``random[j] == 0`` the j-th coefficient is fixed.
129
+ * Each observation's predictor values are independent ``N(0, 1)`` draws
130
+ (or a single replicated draw if the j-th effect is fixed).
131
+ * The standardised response is
132
+ ``y_std = coef_matrix @ predictor_matrix + N(0, sigma * res_cor)``.
133
+ * Columns are then rescaled to their declared (mean, sd, digits) or
134
+ binned into the declared categorical levels.
135
+
136
+ Parameters
137
+ ----------
138
+ fixed
139
+ Standardised fixed-effect coefficients, length ``len(vars)``.
140
+ ``fixed[0]`` is the intercept.
141
+ random
142
+ Standardised random-effect SDs, same length as *fixed*. A value of
143
+ 0 means the corresponding coefficient is fixed across clusters.
144
+ sigma
145
+ Proportion of total variance remaining unexplained at the residual
146
+ level (must be in (0, 1)).
147
+ clusters
148
+ Number of clusters.
149
+ n_per
150
+ ``(mean, sd)`` of the per-cluster sample size. Cluster sizes are
151
+ drawn from a normal truncated at 1.
152
+ vars
153
+ Mapping from variable name to either ``(mean, sd, digits)`` for a
154
+ continuous variable, or a sequence of category labels for a
155
+ categorical variable. The **last** entry must be the cluster id
156
+ (categorical with ``clusters`` unique levels).
157
+ interactions, polynomials
158
+ Optional dictionaries with keys ``"from"`` (predictor indices into
159
+ the *predictor* slot, not the intercept), ``"to"`` (polynomial target
160
+ index, ignored for pure interactions), and ``"coef"`` (effect
161
+ coefficients). Indices are 1-based and count only the predictors
162
+ in ``vars`` excluding the cluster id, matching the R package's
163
+ convention.
164
+ seed
165
+ Optional seed for reproducibility.
166
+
167
+ Returns
168
+ -------
169
+ pandas.DataFrame
170
+ One row per observation, columns in the order of *vars*. Continuous
171
+ variables are rounded to the requested number of digits; categorical
172
+ variables are discretised by quantile binning.
173
+
174
+ Raises
175
+ ------
176
+ ValueError
177
+ On inconsistent *fixed*/*random*/*vars* lengths, sigma out of range,
178
+ cluster-id count mismatch, or ``sum(fixed[1:]**2)**2 >= 1`` (the
179
+ R package's variance-explained guard).
180
+ """
181
+ _check_errors(fixed, random, vars, clusters, sigma)
182
+
183
+ rng = np.random.default_rng(seed)
184
+
185
+ var_names = list(vars.keys())
186
+ cluster_var = var_names[-1]
187
+ predictor_names = var_names[:-1]
188
+ cluster_levels = list(vars[cluster_var]) # type: ignore[arg-type]
189
+ if len(cluster_levels) != clusters:
190
+ # Defensive; _check_errors should have caught this.
191
+ raise ValueError(
192
+ f"Number of clusters ({clusters}) does not match the cluster "
193
+ f"variable {cluster_var!r} (length {len(cluster_levels)})"
194
+ )
195
+
196
+ # Per-cluster sizes (rounded normal, floored at 1).
197
+ mean_n, sd_n = float(n_per[0]), float(n_per[1])
198
+ raw_sizes = rng.normal(mean_n, sd_n, size=clusters)
199
+ sizes = np.maximum(np.rint(raw_sizes).astype(int), 1)
200
+ total_n = int(sizes.sum())
201
+
202
+ # Layout:
203
+ # predictor_matrix shape (total_n, n_pred) -- one column per predictor slot
204
+ # (no intercept column yet).
205
+ # coef_matrix shape (total_n, n_pred + 1)
206
+ # -- extra leading column = intercept.
207
+ n_pred = len(fixed)
208
+ predictor_matrix = np.zeros((total_n, n_pred))
209
+ coef_matrix = np.zeros((total_n, n_pred))
210
+
211
+ row = 0
212
+ for c_idx, size in enumerate(sizes):
213
+ cluster_coefs = rng.normal(loc=np.asarray(fixed), scale=np.asarray(random))
214
+ for j in range(n_pred):
215
+ if random[j] == 0:
216
+ # R uses rnorm(1, 0, 1) and replicates -- one constant
217
+ # value per cluster, not per row.
218
+ values = np.full(size, rng.normal(0.0, 1.0))
219
+ else:
220
+ values = rng.normal(0.0, 1.0, size=size)
221
+ predictor_matrix[row:row + size, j] = values
222
+ coef_matrix[row:row + size, :] = cluster_coefs
223
+ row += size
224
+
225
+ # Intercept column = all ones (R's `mutate(intercept = 1)`).
226
+ predictor_matrix[:, 0] = 1.0
227
+
228
+ # Optional interactions / polynomial terms (additive in the standardized space).
229
+ if interactions is not None:
230
+ predictor_matrix, coef_matrix = _add_interactions(
231
+ predictor_matrix, coef_matrix, interactions
232
+ )
233
+ if polynomials is not None:
234
+ predictor_matrix, coef_matrix = _add_polynomials(
235
+ predictor_matrix, coef_matrix, polynomials
236
+ )
237
+
238
+ # y_std = sum(coef_matrix[k] * predictor_matrix[k]) + noise.
239
+ explained = float(np.sum(np.asarray(fixed[1:]) ** 2) ** 2)
240
+ res_cor = sigma * np.sqrt(max(1.0 - explained, 0.0))
241
+ y_std = (coef_matrix * predictor_matrix).sum(axis=1) + rng.normal(0.0, res_cor, size=total_n)
242
+
243
+ # Build the output frame:
244
+ # * first predictor slot -> response (rescaled/binned).
245
+ # * remaining predictor slots -> raw predictor values, rescaled/binned.
246
+ # * cluster column -> cluster id, repeated according to ``sizes``.
247
+ out = pd.DataFrame(index=range(total_n))
248
+ out[cluster_var] = np.repeat(np.asarray(cluster_levels, dtype=object), sizes)
249
+
250
+ first_spec = vars[predictor_names[0]]
251
+ out[predictor_names[0]] = _apply_spec(y_std, first_spec)
252
+
253
+ for j, name in enumerate(predictor_names[1:], start=1):
254
+ spec = vars[name]
255
+ # predictor_matrix column layout: column 0 = intercept (all 1s);
256
+ # columns 1..n_pred-1 = the random-draw predictor values for the
257
+ # slots 1..n_pred-1. predictor_names[0] is the response (handled
258
+ # above from y_std), so predictor_names[k] for k >= 1 corresponds to
259
+ # predictor_matrix column k.
260
+ col_idx = j
261
+ col = (
262
+ predictor_matrix[:, col_idx]
263
+ if predictor_matrix.shape[1] > col_idx
264
+ else np.zeros(total_n)
265
+ )
266
+ out[name] = _apply_spec(col, spec)
267
+
268
+ return out
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Internal helpers
273
+ # ---------------------------------------------------------------------------
274
+
275
+ def _is_continuous_spec(spec: Any) -> bool:
276
+ """Return True if *spec* is a continuous ``(mean, sd, digits)`` tuple.
277
+
278
+ A continuous spec is a 3-tuple of non-bool numbers. Any list, or a
279
+ tuple of non-numeric elements, is treated as categorical levels.
280
+ """
281
+ return (
282
+ isinstance(spec, tuple)
283
+ and len(spec) == 3
284
+ and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in spec)
285
+ )
286
+
287
+
288
+ def _check_errors(
289
+ fixed: Sequence[float],
290
+ random: Sequence[float],
291
+ vars: Dict[str, VarSpec],
292
+ clusters: int,
293
+ sigma: float,
294
+ ) -> None:
295
+ fixed_l = len(fixed)
296
+ random_l = len(random)
297
+ vars_l = len(vars)
298
+ if fixed_l != random_l:
299
+ raise ValueError(
300
+ f"fixed and random must have the same length ({fixed_l} vs {random_l})"
301
+ )
302
+ if fixed_l != vars_l - 1:
303
+ raise ValueError(
304
+ f"vars must have length len(fixed) + 1 ({fixed_l + 1}); got {vars_l}"
305
+ )
306
+ cluster_var = list(vars.keys())[-1]
307
+ cluster_levels = vars[cluster_var]
308
+ if _is_continuous_spec(cluster_levels):
309
+ raise ValueError(
310
+ f"Final vars entry (cluster variable {cluster_var!r}) must be categorical "
311
+ f"(list/tuple of levels), got {type(cluster_levels).__name__}"
312
+ )
313
+ if not isinstance(cluster_levels, (list, tuple)):
314
+ raise ValueError(
315
+ f"Final vars entry (cluster variable {cluster_var!r}) must be categorical "
316
+ f"(list/tuple of levels), got {type(cluster_levels).__name__}"
317
+ )
318
+ if len(cluster_levels) != clusters:
319
+ raise ValueError(
320
+ f"Number of clusters ({clusters}) must equal length of cluster "
321
+ f"variable {cluster_var!r} ({len(cluster_levels)})"
322
+ )
323
+ if len(set(cluster_levels)) != len(cluster_levels):
324
+ raise ValueError(
325
+ f"Cluster variable {cluster_var!r} has duplicate levels: {cluster_levels}"
326
+ )
327
+ if not (0.0 < sigma < 1.0):
328
+ raise ValueError(f"sigma must be in (0, 1), got {sigma}")
329
+ explained = float(np.sum(np.asarray(fixed[1:]) ** 2) ** 2)
330
+ if explained >= 1.0:
331
+ raise ValueError(
332
+ f"sum(fixed[1:]**2)**2 = {explained} must be < 1 (standardized "
333
+ "coefficients are too large)"
334
+ )
335
+
336
+
337
+ def _rescale_continuous(x: np.ndarray, spec: Tuple[float, float, int]) -> np.ndarray:
338
+ """Rescale *x* to (mean, sd, digits)."""
339
+ mean, sd, digits = float(spec[0]), float(spec[1]), int(spec[2])
340
+ cur_mean = float(np.mean(x))
341
+ cur_sd = float(np.std(x, ddof=0))
342
+ if cur_sd == 0:
343
+ centred = x - cur_mean
344
+ rescaled = centred + mean
345
+ else:
346
+ rescaled = mean + (x - cur_mean) * (sd / cur_sd)
347
+ if digits >= 0:
348
+ return np.round(rescaled, digits)
349
+ return rescaled
350
+
351
+
352
+ def _apply_spec(x: np.ndarray, spec: VarSpec) -> np.ndarray:
353
+ """Apply a continuous or categorical spec to a 1-D array.
354
+
355
+ Mirrors the validation logic in :func:`_check_errors`: a 3-tuple of
356
+ numbers is the continuous ``(mean, sd, digits)`` spec; anything else
357
+ list-or-tuple is categorical levels.
358
+ """
359
+ if _is_continuous_spec(spec):
360
+ return _rescale_continuous(x, spec) # type: ignore[arg-type]
361
+ if not isinstance(spec, (list, tuple)):
362
+ raise TypeError(
363
+ f"var spec must be a (mean, sd, digits) tuple or a list of levels; "
364
+ f"got {type(spec).__name__}"
365
+ )
366
+ return np.asarray(pd.cut(x, bins=len(spec), labels=list(spec)))
367
+
368
+
369
+ def _interaction_polynomial_checks(spec: Dict[str, Sequence]) -> None:
370
+ """Validate the structure of an ``interactions`` dict.
371
+
372
+ Requires all three keys ``from``, ``to``, ``coef``. Use
373
+ :func:`_polynomial_checks` for ``polynomials`` dicts which only need
374
+ ``from`` and ``coef``.
375
+ """
376
+ if set(spec.keys()) != {"from", "to", "coef"}:
377
+ raise ValueError(
378
+ f"interactions dict must have exactly the keys "
379
+ f"'from', 'to', 'coef'; got {sorted(spec.keys())}"
380
+ )
381
+ n_from = len(spec["from"])
382
+ n_to = len(spec["to"])
383
+ n_coef = len(spec["coef"])
384
+ if not (n_from == n_to == n_coef):
385
+ raise ValueError(
386
+ f"interactions arrays have inconsistent lengths: "
387
+ f"from={n_from}, to={n_to}, coef={n_coef}"
388
+ )
389
+
390
+
391
+ def _polynomial_checks(spec: Dict[str, Sequence]) -> None:
392
+ """Validate the structure of a ``polynomials`` dict.
393
+
394
+ Polynomials are a special case of interactions where ``to`` is always
395
+ the same index as ``from``, so the user only needs to supply
396
+ ``from`` and ``coef``.
397
+ """
398
+ if set(spec.keys()) - {"from", "coef"} != set():
399
+ # Either an unknown key, or `to` was supplied (which we don't use).
400
+ unexpected = set(spec.keys()) - {"from", "coef"}
401
+ if "to" in unexpected:
402
+ # `to` is allowed but ignored for backwards compatibility with
403
+ # the R package's shape; warn isn't worth it for a doc mismatch.
404
+ unexpected.remove("to")
405
+ if unexpected:
406
+ raise ValueError(
407
+ f"polynomials dict must have only the keys 'from' and 'coef'; "
408
+ f"got extra {sorted(unexpected)}"
409
+ )
410
+ if "from" not in spec or "coef" not in spec:
411
+ raise ValueError(
412
+ f"polynomials dict must have both 'from' and 'coef' keys; "
413
+ f"got {sorted(spec.keys())}"
414
+ )
415
+ if len(spec["from"]) != len(spec["coef"]):
416
+ raise ValueError(
417
+ f"polynomials arrays have inconsistent lengths: "
418
+ f"from={len(spec['from'])}, coef={len(spec['coef'])}"
419
+ )
420
+
421
+
422
+ def _add_interactions(
423
+ predictor_matrix: np.ndarray,
424
+ coef_matrix: np.ndarray,
425
+ interactions: Dict[str, Sequence],
426
+ ) -> Tuple[np.ndarray, np.ndarray]:
427
+ if not interactions:
428
+ return predictor_matrix, coef_matrix
429
+ _interaction_polynomial_checks(interactions)
430
+
431
+ # R's indices are 1-based over the predictor slots. Our predictor_matrix
432
+ # layout is [intercept, pred_0, pred_1, ...], so slot k lives at column k+1.
433
+ from_idx = [int(i) + 1 for i in interactions["from"]]
434
+ to_idx = [int(i) + 1 for i in interactions["to"]]
435
+ coefs = [float(c) for c in interactions["coef"]]
436
+
437
+ for a, b, c in zip(from_idx, to_idx, coefs):
438
+ new_col = predictor_matrix[:, a] * predictor_matrix[:, b]
439
+ predictor_matrix = np.column_stack([predictor_matrix, new_col])
440
+ coef_matrix = np.column_stack([coef_matrix, np.full(predictor_matrix.shape[0], c)])
441
+ return predictor_matrix, coef_matrix
442
+
443
+
444
+ def _add_polynomials(
445
+ predictor_matrix: np.ndarray,
446
+ coef_matrix: np.ndarray,
447
+ polynomials: Dict[str, Sequence],
448
+ ) -> Tuple[np.ndarray, np.ndarray]:
449
+ if not polynomials:
450
+ return predictor_matrix, coef_matrix
451
+ _polynomial_checks(polynomials)
452
+
453
+ # R's indices are 1-based over the predictor slots. Our predictor_matrix
454
+ # layout is [intercept, pred_0, pred_1, ...], so slot k lives at column k+1.
455
+ var_idx = [int(i) + 1 for i in polynomials["from"]]
456
+ coefs = [float(c) for c in polynomials["coef"]]
457
+ for col, c in zip(var_idx, coefs):
458
+ new_col = predictor_matrix[:, col] ** 2
459
+ predictor_matrix = np.column_stack([predictor_matrix, new_col])
460
+ coef_matrix = np.column_stack([coef_matrix, np.full(predictor_matrix.shape[0], c)])
461
+ return predictor_matrix, coef_matrix