mixedbench 1.0.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.
- mixedbench/__init__.py +1 -0
- mixedbench/contract/__init__.py +21 -0
- mixedbench/contract/materialised.py +124 -0
- mixedbench/contract/problem.py +412 -0
- mixedbench/datasets/__init__.py +11 -0
- mixedbench/datasets/deparse.py +330 -0
- mixedbench/datasets/intermediates.pem +36 -0
- mixedbench/datasets/loader.py +297 -0
- mixedbench/datasets/readers.py +483 -0
- mixedbench/registry/__init__.py +93 -0
- mixedbench/registry/files/SUMMARY.md +259 -0
- mixedbench/registry/files/columns.csv +4090 -0
- mixedbench/registry/files/dimensions.csv +942 -0
- mixedbench/registry/files/excluded.csv +2134 -0
- mixedbench/registry/files/manifest.csv +642 -0
- mixedbench/registry/files/quantiles.yaml +982 -0
- mixedbench/registry/files/registry.csv +715 -0
- mixedbench/registry/files/sources.yaml +845 -0
- mixedbench/registry/row.py +199 -0
- mixedbench-1.0.0.dist-info/METADATA +101 -0
- mixedbench-1.0.0.dist-info/RECORD +24 -0
- mixedbench-1.0.0.dist-info/WHEEL +4 -0
- mixedbench-1.0.0.dist-info/licenses/LICENSE +29 -0
- mixedbench-1.0.0.dist-info/licenses/LICENSE-CC-BY-4.0 +396 -0
mixedbench/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""mixedbench: posterior approximation and prediction on real grouped data for GLMMs."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from mixedbench.contract.materialised import contractHash, load, materialise
|
|
2
|
+
from mixedbench.contract.problem import (
|
|
3
|
+
Prior,
|
|
4
|
+
Problem,
|
|
5
|
+
Refused,
|
|
6
|
+
Scale,
|
|
7
|
+
columnType,
|
|
8
|
+
withComposites,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
'Prior',
|
|
13
|
+
'Problem',
|
|
14
|
+
'Refused',
|
|
15
|
+
'Scale',
|
|
16
|
+
'columnType',
|
|
17
|
+
'contractHash',
|
|
18
|
+
'load',
|
|
19
|
+
'materialise',
|
|
20
|
+
'withComposites',
|
|
21
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""materialised.py defines a problem's files on disk and the contract hash over them.
|
|
2
|
+
|
|
3
|
+
A materialised problem is two files under the loader's cache:
|
|
4
|
+
`table.parquet` holding the table, `problem.json` the record a table cannot and the contract hash.
|
|
5
|
+
Three decisions guide them:
|
|
6
|
+
- the files are the interface: `load` needs neither the loader nor the network,
|
|
7
|
+
so a suite user fetches the sources once per machine;
|
|
8
|
+
- the hash is over values, not file bytes: a parquet writer's options or version do not move it;
|
|
9
|
+
- the hash covers every field and every cell, the contract version among them,
|
|
10
|
+
so `load` refuses files that were edited or are stale.
|
|
11
|
+
|
|
12
|
+
pipeline:
|
|
13
|
+
---------
|
|
14
|
+
a built Problem
|
|
15
|
+
-> its record, every field but the table as JSON values _record
|
|
16
|
+
-> its contract hash contractHash
|
|
17
|
+
-> the table as parquet, the record and the hash as JSON materialise
|
|
18
|
+
-> the problem read back, refused if its hash moved load
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import asdict, fields
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import pandas as pd
|
|
30
|
+
from mixedbench.contract.problem import Prior, Problem, Scale
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# =====================================================================================
|
|
34
|
+
# the files
|
|
35
|
+
# =====================================================================================
|
|
36
|
+
# the package's interface to a problem: written once, loaded anywhere
|
|
37
|
+
def materialise(problem: Problem, cache: Path) -> str:
|
|
38
|
+
"""Write the problem's two files under `cache` and return its contract hash."""
|
|
39
|
+
dir = _problemDir(problem.id, cache)
|
|
40
|
+
dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
problem.table.to_parquet(dir / 'table.parquet', index=False)
|
|
42
|
+
written = contractHash(problem)
|
|
43
|
+
stored = _record(problem) | {'hash': written}
|
|
44
|
+
(dir / 'problem.json').write_text(json.dumps(stored, sort_keys=True, indent=2))
|
|
45
|
+
return written
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load(id: str, cache: Path) -> Problem:
|
|
49
|
+
"""Raises where the files no longer give the hash they were written with (edited, or stale)."""
|
|
50
|
+
dir = _problemDir(id, cache)
|
|
51
|
+
stored = json.loads((dir / 'problem.json').read_text())
|
|
52
|
+
problem = Problem(
|
|
53
|
+
id=stored['id'],
|
|
54
|
+
table=pd.read_parquet(dir / 'table.parquet'),
|
|
55
|
+
design=tuple(stored['design']),
|
|
56
|
+
response=stored['response'],
|
|
57
|
+
factors=tuple(stored['factors']),
|
|
58
|
+
levels={column: tuple(levels) for column, levels in stored['levels'].items()},
|
|
59
|
+
scales={
|
|
60
|
+
regime: {column: Scale(**scale) for column, scale in columns.items()}
|
|
61
|
+
for regime, columns in stored['scales'].items()
|
|
62
|
+
},
|
|
63
|
+
dropped_share=stored['dropped_share'],
|
|
64
|
+
trials=stored['trials'],
|
|
65
|
+
family=stored['family'],
|
|
66
|
+
link=stored['link'],
|
|
67
|
+
prior={
|
|
68
|
+
name: Prior(prior['distribution'], tuple(prior['parameters']))
|
|
69
|
+
for name, prior in stored['prior'].items()
|
|
70
|
+
},
|
|
71
|
+
parameters=tuple(stored['parameters']),
|
|
72
|
+
correlations={factor: tuple(names) for factor, names in stored['correlations'].items()},
|
|
73
|
+
contract=stored['contract'],
|
|
74
|
+
)
|
|
75
|
+
if contractHash(problem) != stored['hash']:
|
|
76
|
+
raise ValueError(f'{id}: the files in {dir} do not match their contract hash; rebuild them')
|
|
77
|
+
return problem
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _problemDir(id: str, cache: Path) -> Path:
|
|
81
|
+
"""Named by the id's sha256: an object name may hold `/`, `!`, commas and spaces,
|
|
82
|
+
and the JSON carries the id itself."""
|
|
83
|
+
return Path(cache) / 'problems' / hashlib.sha256(id.encode()).hexdigest()[:16]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# =====================================================================================
|
|
87
|
+
# the contract hash
|
|
88
|
+
# =====================================================================================
|
|
89
|
+
# over values, so that a problem is the same whatever wrote its files
|
|
90
|
+
def contractHash(problem: Problem) -> str:
|
|
91
|
+
"""sha256 over the record with sorted keys,
|
|
92
|
+
then every column in table order as its name, dtype and little-endian bytes."""
|
|
93
|
+
canonical = json.dumps(_record(problem), sort_keys=True, separators=(',', ':'))
|
|
94
|
+
digest = hashlib.sha256(canonical.encode())
|
|
95
|
+
for name, column in problem.table.items():
|
|
96
|
+
dtype, values = _columnBytes(column)
|
|
97
|
+
digest.update(f'{name}\0{dtype}\0'.encode())
|
|
98
|
+
digest.update(values)
|
|
99
|
+
return digest.hexdigest()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _record(problem: Problem) -> dict:
|
|
103
|
+
"""Every field but the table, as JSON values: a numpy level as its Python number."""
|
|
104
|
+
values = {f.name: getattr(problem, f.name) for f in fields(Problem) if f.name != 'table'}
|
|
105
|
+
return json.loads(json.dumps(values, default=_jsonValue))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _jsonValue(value: Scale | Prior | np.generic) -> dict | int | float:
|
|
109
|
+
if isinstance(value, np.generic):
|
|
110
|
+
return value.item()
|
|
111
|
+
return asdict(value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _columnBytes(column: pd.Series) -> tuple[str, bytes]:
|
|
115
|
+
"""A regime column as its codes, its categories in the dtype; a float as float64; an
|
|
116
|
+
integer in its own width."""
|
|
117
|
+
if isinstance(column.dtype, pd.CategoricalDtype):
|
|
118
|
+
categories = ','.join(column.cat.categories)
|
|
119
|
+
codes = column.cat.codes.to_numpy()
|
|
120
|
+
return f'category[{categories}]', codes.astype(codes.dtype.newbyteorder('<')).tobytes()
|
|
121
|
+
if pd.api.types.is_float_dtype(column.dtype):
|
|
122
|
+
return 'float64', column.to_numpy(dtype='<f8').tobytes()
|
|
123
|
+
values = column.to_numpy()
|
|
124
|
+
return str(values.dtype), values.astype(values.dtype.newbyteorder('<')).tobytes()
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""problem.py defines the contract: a registry row and its table in, a `Problem` out.
|
|
2
|
+
|
|
3
|
+
A problem is the table a model is fitted to and what it takes to fit and score it:
|
|
4
|
+
the coded design and response, the family, the prior, the splits, the scales, the parameter names.
|
|
5
|
+
`Problem.build` makes one from the typed row and the loader's typed table, by five decisions:
|
|
6
|
+
- reproducible: a problem is a pure function of the pins, the row and the contract version;
|
|
7
|
+
its one random step, the splits, is seeded by the problem id;
|
|
8
|
+
- uniform: the response type fixes the family, one prior serves every problem,
|
|
9
|
+
and one typing rule decides each column's kind (the row's documented prior is not used);
|
|
10
|
+
- complete cases: a row missing a model value is dropped,
|
|
11
|
+
a row outside the contract (categorical response, censored model) raises `Refused`;
|
|
12
|
+
- coded on the full table, scaled per regime: treatment contrasts against the first loaded level,
|
|
13
|
+
the table in raw units, each regime's moments recorded over the rows a fit sees;
|
|
14
|
+
- brms's names in brms's block order, so draws and scores join on them.
|
|
15
|
+
|
|
16
|
+
pipeline:
|
|
17
|
+
---------
|
|
18
|
+
a typed registry row and its loaded table
|
|
19
|
+
-> refused if outside the contract _refuseUncovered
|
|
20
|
+
-> composites added, incomplete rows dropped withComposites, _keptRows
|
|
21
|
+
-> the coded design, response and factors _kinds, _coded, _design, _codeFactor, _response
|
|
22
|
+
-> the regimes `rows` and `groups` _splits
|
|
23
|
+
-> each regime's scales _scales
|
|
24
|
+
-> the family, prior and parameter names _prior, _parameters
|
|
25
|
+
-> the problem problem
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from importlib.metadata import version
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
from mixedbench.registry.row import Row, parts
|
|
37
|
+
|
|
38
|
+
# =====================================================================================
|
|
39
|
+
# the problem
|
|
40
|
+
# =====================================================================================
|
|
41
|
+
# what `build` returns or raises, and the records a problem holds
|
|
42
|
+
CONTRACT = int(version('mixedbench').partition('.')[0]) # the package's major version
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Problem:
|
|
47
|
+
id: str # <source>/<object>/<model_index>
|
|
48
|
+
table: pd.DataFrame # the coded columns in raw units; one row per kept row
|
|
49
|
+
# column roles in `table`: the design columns in order, `Intercept` first; the response;
|
|
50
|
+
# the grouping factors as 0-based codes
|
|
51
|
+
design: tuple[str, ...]
|
|
52
|
+
response: str
|
|
53
|
+
factors: tuple[str, ...]
|
|
54
|
+
levels: dict[str, tuple] # per recoded column, the level of each code (binary: 0 then 1)
|
|
55
|
+
# per regime (`full`, `rows`, `groups` where the problem has it), the moments of each
|
|
56
|
+
# continuous covariate and a continuous response over the rows a fit sees
|
|
57
|
+
scales: dict[str, dict[str, Scale]]
|
|
58
|
+
dropped_share: float # of the loaded rows, those missing a model value
|
|
59
|
+
trials: str | None # the trials column of a binomial response
|
|
60
|
+
family: str # brms's names, by the family rule
|
|
61
|
+
link: str
|
|
62
|
+
prior: dict[str, Prior] # per brms class the problem has
|
|
63
|
+
parameters: tuple[str, ...] # the global parameters by the naming rule, in order
|
|
64
|
+
correlations: dict[str, tuple[str, ...]] # per `cor[<factor>]` block, its elements in order
|
|
65
|
+
contract: int # the version of the contract the problem was built under
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def build(cls, row: Row, table: pd.DataFrame) -> Problem:
|
|
69
|
+
"""Raises `Refused` where the contract does not cover the row."""
|
|
70
|
+
return _Builder(row, table).problem()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Prior:
|
|
75
|
+
distribution: str # brms's name: normal, student_t (half on a scale), lkj
|
|
76
|
+
parameters: tuple[float, ...]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Scale:
|
|
81
|
+
mean: float
|
|
82
|
+
sd: float # the sample standard deviation, as R's `scale`
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Refused(Exception):
|
|
86
|
+
"""The contract states no problem for the row: the message names the row, `reason` says
|
|
87
|
+
why."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, id: str, reason: str) -> None:
|
|
90
|
+
super().__init__(f'{id}: {reason}')
|
|
91
|
+
self.reason = reason
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# =====================================================================================
|
|
95
|
+
# the builder
|
|
96
|
+
# =====================================================================================
|
|
97
|
+
# the pipeline behind `Problem.build`, one step per rule of the contract
|
|
98
|
+
Coded = dict[str, dict[str, pd.Series]] # per covariate or interaction, its coded columns
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _Builder:
|
|
102
|
+
# the family rule: by response type, brms's family and link
|
|
103
|
+
FAMILY = {
|
|
104
|
+
'continuous': ('gaussian', 'identity'),
|
|
105
|
+
'binary': ('bernoulli', 'logit'),
|
|
106
|
+
'count': ('poisson', 'log'),
|
|
107
|
+
'binomial': ('binomial', 'logit'),
|
|
108
|
+
'ordinal': ('cumulative', 'logit'),
|
|
109
|
+
}
|
|
110
|
+
CENSORED = ('cox', 'weibull')
|
|
111
|
+
# the prior: on the standardised scale, per brms class
|
|
112
|
+
PRIOR = {
|
|
113
|
+
'Intercept': Prior('normal', (0, 2.5)),
|
|
114
|
+
'b': Prior('normal', (0, 2.5)),
|
|
115
|
+
'sd': Prior('student_t', (3, 0, 2.5)),
|
|
116
|
+
'cor': Prior('lkj', (1,)),
|
|
117
|
+
'sigma': Prior('student_t', (3, 0, 2.5)),
|
|
118
|
+
}
|
|
119
|
+
# the splits: two regimes, each a column of `seen` and `held`
|
|
120
|
+
REGIME = pd.CategoricalDtype(['seen', 'held'])
|
|
121
|
+
|
|
122
|
+
def __init__(self, row: Row, table: pd.DataFrame) -> None:
|
|
123
|
+
self.row = row
|
|
124
|
+
self.id = row.id
|
|
125
|
+
self._refuseUncovered()
|
|
126
|
+
self.table = self._keptRows(withComposites(table, row.grouping_factors))
|
|
127
|
+
if self.table.empty: # nlme Phenobarb: doses and responses on different rows
|
|
128
|
+
raise Refused(self.id, 'no row has every model value')
|
|
129
|
+
self.dropped_share = (len(table) - len(self.table)) / len(table)
|
|
130
|
+
|
|
131
|
+
def problem(self) -> Problem:
|
|
132
|
+
kinds = self._kinds()
|
|
133
|
+
coded = self._coded(kinds)
|
|
134
|
+
design = self._design(coded)
|
|
135
|
+
factors = {factor: self._codeFactor(factor) for factor in self.row.grouping_factors}
|
|
136
|
+
regimes = self._splits(factors)
|
|
137
|
+
table = pd.DataFrame(design | self._response() | factors | regimes).reset_index(drop=True)
|
|
138
|
+
correlations = self._correlations(coded)
|
|
139
|
+
family, link = self.FAMILY[self.row.response_type]
|
|
140
|
+
return Problem(
|
|
141
|
+
id=self.id,
|
|
142
|
+
table=table,
|
|
143
|
+
design=tuple(design),
|
|
144
|
+
response=self.row.response,
|
|
145
|
+
factors=self.row.grouping_factors,
|
|
146
|
+
levels=self._levels(kinds),
|
|
147
|
+
scales=self._scales(table, kinds),
|
|
148
|
+
dropped_share=self.dropped_share,
|
|
149
|
+
trials=self.row.trials or None,
|
|
150
|
+
family=family,
|
|
151
|
+
link=link,
|
|
152
|
+
prior=self._prior(design, correlations),
|
|
153
|
+
parameters=self._parameters(design, coded, correlations),
|
|
154
|
+
correlations=correlations,
|
|
155
|
+
contract=CONTRACT,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# -------------------------------------------------------------------------------------
|
|
159
|
+
# rows -> coding -> splits -> scales -> model
|
|
160
|
+
# -------------------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def _refuseUncovered(self) -> None:
|
|
163
|
+
"""A categorical response and a censored model are outside suite 1.1; every other
|
|
164
|
+
`family` takes the family rule."""
|
|
165
|
+
if self.row.response_type == 'categorical':
|
|
166
|
+
raise Refused(self.id, 'a categorical response is outside suite 1.1 (ADR 0001)')
|
|
167
|
+
if self.row.family in self.CENSORED:
|
|
168
|
+
reason = f'family {self.row.family} is a censored model, outside suite 1.1 (ADR 0001)'
|
|
169
|
+
raise Refused(self.id, reason)
|
|
170
|
+
|
|
171
|
+
def _keptRows(self, table: pd.DataFrame) -> pd.DataFrame:
|
|
172
|
+
"""A row with a missing value in any model column is dropped; a composite factor is
|
|
173
|
+
missing where a part is."""
|
|
174
|
+
model = [self.row.response, *self.row.covariates]
|
|
175
|
+
for factor in self.row.grouping_factors:
|
|
176
|
+
model += parts(factor)
|
|
177
|
+
if self.row.trials:
|
|
178
|
+
model.append(self.row.trials)
|
|
179
|
+
return table.dropna(subset=model)
|
|
180
|
+
|
|
181
|
+
def _kinds(self) -> dict[str, str]:
|
|
182
|
+
"""Per covariate, its kind by the typing rule."""
|
|
183
|
+
kinds = {}
|
|
184
|
+
for column in self.row.covariates:
|
|
185
|
+
kinds[column], _ = columnType(self.table[column], self.row.types.get(column, ''))
|
|
186
|
+
return kinds
|
|
187
|
+
|
|
188
|
+
def _coded(self, kinds: dict[str, str]) -> Coded:
|
|
189
|
+
"""Per covariate, then per interaction, its coded columns."""
|
|
190
|
+
coded = {}
|
|
191
|
+
for covariate, kind in kinds.items():
|
|
192
|
+
coded[covariate] = self._codeCovariate(covariate, kind)
|
|
193
|
+
for term in self.row.interactions:
|
|
194
|
+
coded[term] = self._codeInteraction(term, coded)
|
|
195
|
+
return coded
|
|
196
|
+
|
|
197
|
+
def _codeCovariate(self, column: str, kind: str) -> dict[str, pd.Series]:
|
|
198
|
+
"""Continuous raw; binary 0/1 with the first loaded level 0; a categorical or ordered
|
|
199
|
+
factor as K − 1 treatment contrasts against the first level, `<column>[<level>]`."""
|
|
200
|
+
values = self.table[column]
|
|
201
|
+
if kind == 'continuous':
|
|
202
|
+
return {column: values.astype(float)}
|
|
203
|
+
loaded = self._levelOrder(values)
|
|
204
|
+
if kind == 'binary':
|
|
205
|
+
return {column: (values != loaded[0]).astype(float)}
|
|
206
|
+
return {f'{column}[{level}]': (values == level).astype(float) for level in loaded[1:]}
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _codeInteraction(term: str, coded: Coded) -> dict[str, pd.Series]:
|
|
210
|
+
"""The products of the coded columns of its parts, `<a>:<b>` per pair in order."""
|
|
211
|
+
columns = {'': 1.0}
|
|
212
|
+
for part in parts(term):
|
|
213
|
+
products = {}
|
|
214
|
+
for name, column in columns.items():
|
|
215
|
+
for partName, partColumn in coded[part].items():
|
|
216
|
+
products[f'{name}:{partName}'.removeprefix(':')] = column * partColumn
|
|
217
|
+
columns = products
|
|
218
|
+
return columns
|
|
219
|
+
|
|
220
|
+
def _design(self, coded: Coded) -> dict[str, pd.Series]:
|
|
221
|
+
"""`Intercept` first, then the coded columns in the row's order."""
|
|
222
|
+
design = {'Intercept': pd.Series(1.0, index=self.table.index)}
|
|
223
|
+
for columns in coded.values():
|
|
224
|
+
design.update(columns)
|
|
225
|
+
return design
|
|
226
|
+
|
|
227
|
+
def _codeFactor(self, factor: str) -> pd.Series:
|
|
228
|
+
values = self.table[factor]
|
|
229
|
+
codes = pd.Categorical(values, categories=self._levelOrder(values)).codes
|
|
230
|
+
return pd.Series(codes, index=self.table.index)
|
|
231
|
+
|
|
232
|
+
def _response(self) -> dict[str, pd.Series]:
|
|
233
|
+
"""By `response_type`: continuous, count and binomial successes as loaded, binary 0/1
|
|
234
|
+
with the first loaded level 0, ordinal its 1-based index in the loaded level order; a
|
|
235
|
+
binomial's trials beside it."""
|
|
236
|
+
values = self.table[self.row.response]
|
|
237
|
+
if self.row.response_type == 'binary':
|
|
238
|
+
values = (values != self._levelOrder(values)[0]).astype(int)
|
|
239
|
+
if self.row.response_type == 'ordinal':
|
|
240
|
+
codes = pd.Categorical(values, categories=self._levelOrder(values)).codes
|
|
241
|
+
values = pd.Series(codes + 1, index=self.table.index)
|
|
242
|
+
response = {self.row.response: values}
|
|
243
|
+
if self.row.trials:
|
|
244
|
+
response[self.row.trials] = self.table[self.row.trials]
|
|
245
|
+
return response
|
|
246
|
+
|
|
247
|
+
def _levels(self, kinds: dict[str, str]) -> dict[str, tuple]:
|
|
248
|
+
"""The loaded level order of every recoded column: a covariate that is not continuous,
|
|
249
|
+
a binary or ordinal response, a grouping factor."""
|
|
250
|
+
recoded = [column for column, kind in kinds.items() if kind != 'continuous']
|
|
251
|
+
if self.row.response_type in ('binary', 'ordinal'):
|
|
252
|
+
recoded.append(self.row.response)
|
|
253
|
+
recoded += self.row.grouping_factors
|
|
254
|
+
return {column: self._levelOrder(self.table[column]) for column in recoded}
|
|
255
|
+
|
|
256
|
+
def _splits(self, factors: dict[str, pd.Series]) -> dict[str, pd.Series]:
|
|
257
|
+
"""Seeded by the first 8 bytes of sha256 of the id; rows draw before groups. A first
|
|
258
|
+
factor of two groups has no `groups` regime: holding one would leave one."""
|
|
259
|
+
seed = int.from_bytes(hashlib.sha256(self.id.encode()).digest()[:8], 'big')
|
|
260
|
+
rng = np.random.default_rng(seed)
|
|
261
|
+
regimes = {'rows': self._regime(self._heldRows(rng, factors))}
|
|
262
|
+
first = factors[self.row.grouping_factors[0]]
|
|
263
|
+
if first.max() + 1 > 2:
|
|
264
|
+
regimes['groups'] = self._regime(self._heldGroups(rng, first))
|
|
265
|
+
return regimes
|
|
266
|
+
|
|
267
|
+
def _heldRows(self, rng: np.random.Generator, factors: dict[str, pd.Series]) -> np.ndarray:
|
|
268
|
+
"""Every group of every factor keeps seen the row it shows first in the seeded order;
|
|
269
|
+
of the other rows, the first nearest whole number to 10% of all rows are held."""
|
|
270
|
+
n = len(self.table)
|
|
271
|
+
order = self._seededOrder(rng, n) # (n,) row positions
|
|
272
|
+
spare = np.ones(n, dtype=bool) # (n,) in seeded order
|
|
273
|
+
for codes in factors.values():
|
|
274
|
+
spare &= pd.Series(codes.to_numpy()[order]).duplicated().to_numpy()
|
|
275
|
+
held = np.zeros(n, dtype=bool) # (n,)
|
|
276
|
+
held[order[spare][: (n + 5) // 10]] = True
|
|
277
|
+
return held
|
|
278
|
+
|
|
279
|
+
def _heldGroups(self, rng: np.random.Generator, codes: pd.Series) -> np.ndarray:
|
|
280
|
+
"""The nearest whole number to 10% of the groups, at least one, at least two left."""
|
|
281
|
+
groups = codes.max() + 1
|
|
282
|
+
count = min(max(1, (groups + 5) // 10), groups - 2)
|
|
283
|
+
held = self._seededOrder(rng, groups)[:count]
|
|
284
|
+
return np.isin(codes.to_numpy(), held) # (n,)
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _seededOrder(rng: np.random.Generator, n: int) -> np.ndarray:
|
|
288
|
+
"""An argsort of uniform draws, whose stream numpy keeps across versions (a shuffle's
|
|
289
|
+
algorithm it does not promise)."""
|
|
290
|
+
return np.argsort(rng.random(n), kind='stable') # (n,)
|
|
291
|
+
|
|
292
|
+
def _regime(self, held: np.ndarray) -> pd.Series:
|
|
293
|
+
regime = pd.Categorical.from_codes(held.astype(int), dtype=self.REGIME)
|
|
294
|
+
return pd.Series(regime, index=self.table.index)
|
|
295
|
+
|
|
296
|
+
def _scales(self, table: pd.DataFrame, kinds: dict[str, str]) -> dict[str, dict[str, Scale]]:
|
|
297
|
+
"""The standardisation per regime on the rows a fit sees; the table stays in raw units."""
|
|
298
|
+
columns = [column for column, kind in kinds.items() if kind == 'continuous']
|
|
299
|
+
if self.row.response_type == 'continuous':
|
|
300
|
+
columns.append(self.row.response)
|
|
301
|
+
seen = {'full': pd.Series(True, index=table.index)}
|
|
302
|
+
for regime in ('rows', 'groups'):
|
|
303
|
+
if regime in table:
|
|
304
|
+
seen[regime] = table[regime] == 'seen'
|
|
305
|
+
scales = {}
|
|
306
|
+
for regime, rows in seen.items():
|
|
307
|
+
values = table.loc[rows, columns]
|
|
308
|
+
scales[regime] = {
|
|
309
|
+
c: Scale(float(values[c].mean()), float(values[c].std())) for c in columns
|
|
310
|
+
}
|
|
311
|
+
return scales
|
|
312
|
+
|
|
313
|
+
def _correlations(self, coded: Coded) -> dict[str, tuple[str, ...]]:
|
|
314
|
+
"""Per factor with a correlated slope, the elements of `cor[<factor>]`: `Intercept`,
|
|
315
|
+
then the coded columns of its correlated slopes in order."""
|
|
316
|
+
correlations = {}
|
|
317
|
+
for term in self.row.random_slopes:
|
|
318
|
+
if term.correlated:
|
|
319
|
+
elements = correlations.get(term.factor, ('Intercept',))
|
|
320
|
+
correlations[term.factor] = elements + tuple(coded[term.covariate])
|
|
321
|
+
return correlations
|
|
322
|
+
|
|
323
|
+
def _prior(
|
|
324
|
+
self, design: dict[str, pd.Series], correlations: dict[str, tuple[str, ...]]
|
|
325
|
+
) -> dict[str, Prior]:
|
|
326
|
+
"""The prior over the classes the problem has (brms refuses one on an absent
|
|
327
|
+
class): no `Intercept` on an ordinal model, whose thresholds take brms's default."""
|
|
328
|
+
classes = []
|
|
329
|
+
if self.row.response_type != 'ordinal':
|
|
330
|
+
classes.append('Intercept')
|
|
331
|
+
if len(design) > 1:
|
|
332
|
+
classes.append('b')
|
|
333
|
+
classes.append('sd')
|
|
334
|
+
if correlations:
|
|
335
|
+
classes.append('cor')
|
|
336
|
+
if self.row.response_type == 'continuous':
|
|
337
|
+
classes.append('sigma')
|
|
338
|
+
return {name: self.PRIOR[name] for name in classes}
|
|
339
|
+
|
|
340
|
+
def _parameters(
|
|
341
|
+
self,
|
|
342
|
+
design: dict[str, pd.Series],
|
|
343
|
+
coded: Coded,
|
|
344
|
+
correlations: dict[str, tuple[str, ...]],
|
|
345
|
+
) -> tuple[str, ...]:
|
|
346
|
+
"""brms's block order: the fixed effects (an ordinal model's thresholds `Intercept[k]`
|
|
347
|
+
in place of `Intercept`, brms's name without its `b_`), per factor `sd[<factor>]`, the
|
|
348
|
+
slopes `sd[<x>|<factor>]` and `cor[<factor>]` where a slope is correlated, `sigma`."""
|
|
349
|
+
names = list(design)
|
|
350
|
+
if self.row.response_type == 'ordinal':
|
|
351
|
+
thresholds = len(self._levelOrder(self.table[self.row.response])) - 1
|
|
352
|
+
names = [f'Intercept[{k}]' for k in range(1, thresholds + 1)] + names[1:]
|
|
353
|
+
for factor in self.row.grouping_factors:
|
|
354
|
+
names += self._randomEffects(factor, coded)
|
|
355
|
+
if factor in correlations:
|
|
356
|
+
names.append(f'cor[{factor}]')
|
|
357
|
+
if self.row.response_type == 'continuous':
|
|
358
|
+
names.append('sigma')
|
|
359
|
+
return tuple(names)
|
|
360
|
+
|
|
361
|
+
def _randomEffects(self, factor: str, coded: Coded) -> list[str]:
|
|
362
|
+
names = [f'sd[{factor}]']
|
|
363
|
+
for term in self.row.random_slopes:
|
|
364
|
+
if term.factor == factor:
|
|
365
|
+
names += [f'sd[{slope}|{factor}]' for slope in coded[term.covariate]]
|
|
366
|
+
return names
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _levelOrder(values: pd.Series) -> tuple:
|
|
370
|
+
"""The loaded level order: a categorical's categories present in the column, else the
|
|
371
|
+
sorted distinct values (what the loader gives a plain column declared categorical)."""
|
|
372
|
+
if isinstance(values.dtype, pd.CategoricalDtype):
|
|
373
|
+
return tuple(values.cat.remove_unused_categories().cat.categories)
|
|
374
|
+
return tuple(sorted(values.dropna().unique()))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# =====================================================================================
|
|
378
|
+
# the rules shared with the survey
|
|
379
|
+
# =====================================================================================
|
|
380
|
+
# the survey measures a row's design width with them, so it is the width a build gives
|
|
381
|
+
MAX_INTEGER_LEVELS = 10
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def columnType(values: pd.Series, declared: str) -> tuple[str, int | str]:
|
|
385
|
+
"""A column's kind and its level count K (blank if continuous); the first matching rule wins."""
|
|
386
|
+
distinct = values.dropna().nunique()
|
|
387
|
+
dtype = values.dtype
|
|
388
|
+
if isinstance(dtype, pd.CategoricalDtype) and dtype.ordered:
|
|
389
|
+
return 'ordered', distinct
|
|
390
|
+
if distinct == 2:
|
|
391
|
+
return 'binary', 2
|
|
392
|
+
if declared == 'continuous':
|
|
393
|
+
return 'continuous', ''
|
|
394
|
+
if isinstance(dtype, pd.CategoricalDtype) or pd.api.types.is_string_dtype(dtype):
|
|
395
|
+
return 'categorical', distinct
|
|
396
|
+
if pd.api.types.is_integer_dtype(dtype) and distinct <= MAX_INTEGER_LEVELS:
|
|
397
|
+
return 'categorical', distinct
|
|
398
|
+
return 'continuous', ''
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def withComposites(table: pd.DataFrame, factors: tuple[str, ...]) -> pd.DataFrame:
|
|
402
|
+
"""Adds each composite factor `a:b` as a column of its parts' joined levels;
|
|
403
|
+
a row missing a part has none."""
|
|
404
|
+
composites = {}
|
|
405
|
+
for factor in factors:
|
|
406
|
+
columns = parts(factor)
|
|
407
|
+
if len(columns) > 1:
|
|
408
|
+
composite = table[columns[0]].astype(str)
|
|
409
|
+
for part in columns[1:]:
|
|
410
|
+
composite = composite + ':' + table[part].astype(str) # missing stays missing
|
|
411
|
+
composites[factor] = composite
|
|
412
|
+
return table.assign(**composites)
|