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.
- py_flexplot-0.8.2.dist-info/METADATA +272 -0
- py_flexplot-0.8.2.dist-info/RECORD +16 -0
- py_flexplot-0.8.2.dist-info/WHEEL +5 -0
- py_flexplot-0.8.2.dist-info/licenses/LICENSE +21 -0
- py_flexplot-0.8.2.dist-info/top_level.txt +1 -0
- pyflexplot/__init__.py +60 -0
- pyflexplot/bluepill.py +461 -0
- pyflexplot/core.py +3410 -0
- pyflexplot/descriptives.py +287 -0
- pyflexplot/ebbr.py +148 -0
- pyflexplot/flex_nn.py +583 -0
- pyflexplot/ml.py +175 -0
- pyflexplot/quality.py +372 -0
- pyflexplot/sem.py +195 -0
- pyflexplot/stats.py +803 -0
- pyflexplot/uncertainty.py +185 -0
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
|