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 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)
@@ -0,0 +1,11 @@
1
+ from mixedbench.datasets.loader import (
2
+ ColumnType,
3
+ DataObject,
4
+ Loader,
5
+ Manifest,
6
+ ManifestRow,
7
+ Source,
8
+ fetch,
9
+ )
10
+
11
+ __all__ = ['ColumnType', 'DataObject', 'Loader', 'Manifest', 'ManifestRow', 'Source', 'fetch']