pyplasmode 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.
pyplasmode/generate.py ADDED
@@ -0,0 +1,349 @@
1
+ """Composition of empirical resampling, truth materialisation and outcome generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+
10
+ from pyplasmode._validation import indices as integer_indices
11
+ from pyplasmode._validation import integer
12
+ from pyplasmode.data import Population, PopulationPartition, SamplingMethod, sample_population
13
+ from pyplasmode.outcomes import (
14
+ BinaryGeneratedOutcome,
15
+ ContinuousGeneratedOutcome,
16
+ CountGeneratedOutcome,
17
+ GeneratedOutcome,
18
+ OrdinalGeneratedOutcome,
19
+ OutcomeSpec,
20
+ SurvivalGeneratedOutcome,
21
+ generate_outcome,
22
+ )
23
+ from pyplasmode.structures import FeatureSets
24
+ from pyplasmode.truth import (
25
+ CorrelatedTruth,
26
+ CustomTruth,
27
+ MaterializedTruth,
28
+ SparseTruth,
29
+ TruthSpec,
30
+ materialize_truth,
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Plasmode:
36
+ """An in-memory truth-known sample ready for a user-controlled modelling workflow."""
37
+
38
+ X: NDArray[np.float64]
39
+ feature_ids: tuple[str, ...]
40
+ outcome: GeneratedOutcome
41
+ truth: MaterializedTruth
42
+ source_indices: NDArray[np.int64]
43
+ seed: int
44
+ sampling_method: SamplingMethod
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class PartitionSampleSizes:
49
+ """Requested sampled row counts for the three source roles."""
50
+
51
+ training: int
52
+ validation: int
53
+ evaluation: int
54
+
55
+ def __post_init__(self) -> None:
56
+ """Require a positive integer count for every role."""
57
+ for value in (self.training, self.validation, self.evaluation):
58
+ integer(value, "partition sample size", minimum=1)
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class PartitionedPlasmode:
63
+ """Three generated roles with original empirical source-row lineage."""
64
+
65
+ training: Plasmode
66
+ validation: Plasmode
67
+ evaluation: Plasmode
68
+ training_source_indices: NDArray[np.int64]
69
+ validation_source_indices: NDArray[np.int64]
70
+ evaluation_source_indices: NDArray[np.int64]
71
+
72
+ def __post_init__(self) -> None:
73
+ """Prove that no original source identity crosses generated roles."""
74
+ samples = (self.training, self.validation, self.evaluation)
75
+ lineages = tuple(
76
+ integer_indices(values, "generated source lineage")
77
+ for values in (
78
+ self.training_source_indices,
79
+ self.validation_source_indices,
80
+ self.evaluation_source_indices,
81
+ )
82
+ )
83
+ if any(
84
+ lineage.ndim != 1 or lineage.size != sample.X.shape[0]
85
+ for lineage, sample in zip(lineages, samples, strict=True)
86
+ ):
87
+ raise ValueError("generated role lineage is incomplete")
88
+ identities = tuple(set(values.tolist()) for values in lineages)
89
+ if any(
90
+ left & right
91
+ for position, left in enumerate(identities)
92
+ for right in identities[position + 1 :]
93
+ ):
94
+ raise ValueError("an empirical source identity crosses generated roles")
95
+ for name, lineage in zip(
96
+ (
97
+ "training_source_indices",
98
+ "validation_source_indices",
99
+ "evaluation_source_indices",
100
+ ),
101
+ lineages,
102
+ strict=True,
103
+ ):
104
+ frozen = np.array(lineage, copy=True)
105
+ frozen.setflags(write=False)
106
+ object.__setattr__(self, name, frozen)
107
+
108
+
109
+ def generate(
110
+ population: Population,
111
+ *,
112
+ truth: TruthSpec,
113
+ outcome: OutcomeSpec,
114
+ sample_size: int,
115
+ seed: int,
116
+ sampling_method: SamplingMethod = "with_replacement",
117
+ feature_sets: FeatureSets | None = None,
118
+ ) -> Plasmode:
119
+ """Resample empirical rows, construct truth and generate a calibrated outcome.
120
+
121
+ Args:
122
+ population: Source matrix of shape (participants, features).
123
+ truth: Mechanism defining latent signal and recovery targets.
124
+ outcome: Observable outcome targets and effect per signal standard deviation.
125
+ sample_size: Positive number of generated rows.
126
+ seed: Non-negative seed; separate child streams control rows, truth and outcome.
127
+ sampling_method: With replacement by default; without replacement cannot
128
+ exceed the source population size.
129
+ feature_sets: Named groups or modules used by the truth specification.
130
+
131
+ Returns:
132
+ An in-memory plasmode with read-only arrays and local source-row indices.
133
+
134
+ Raises:
135
+ ValueError: Invalid settings or an infeasible truth or sampling request.
136
+ CalibrationError: Outcome targets cannot be represented by the selected model.
137
+ RealizationError: A sampled outcome exceeds its diagnostic tolerance.
138
+ """
139
+ integer(seed, "seed")
140
+ row_seed, truth_seed, outcome_seed = np.random.SeedSequence(seed).spawn(3)
141
+ sampled = sample_population(
142
+ population,
143
+ sample_size,
144
+ np.random.default_rng(row_seed),
145
+ method=sampling_method,
146
+ )
147
+ materialized = materialize_truth(
148
+ truth,
149
+ sampled.as_population(),
150
+ seed=int(truth_seed.generate_state(1, dtype=np.uint32)[0]),
151
+ feature_sets=feature_sets,
152
+ )
153
+ generated = generate_outcome(outcome, materialized.signal, np.random.default_rng(outcome_seed))
154
+ return Plasmode(
155
+ sampled.X,
156
+ sampled.feature_ids,
157
+ generated,
158
+ materialized,
159
+ sampled.source_indices,
160
+ seed,
161
+ sampling_method,
162
+ )
163
+
164
+
165
+ def _freeze_partition_truth(
166
+ truth: TruthSpec,
167
+ population: Population,
168
+ *,
169
+ seed: int,
170
+ feature_sets: FeatureSets | None,
171
+ ) -> TruthSpec:
172
+ """Freeze training-derived identities before generating other roles."""
173
+ if isinstance(truth, CustomTruth):
174
+ raise ValueError("partitioned generation requires a reusable declarative truth")
175
+ if isinstance(truth, SparseTruth) and truth.count is not None:
176
+ materialized = materialize_truth(
177
+ truth,
178
+ population,
179
+ seed=seed,
180
+ feature_sets=feature_sets,
181
+ )
182
+ return SparseTruth(
183
+ features=materialized.direct_features,
184
+ weights=tuple(item.weight for item in materialized.weights),
185
+ max_absolute_correlation=truth.max_absolute_correlation,
186
+ )
187
+ if (
188
+ isinstance(truth, CorrelatedTruth)
189
+ and truth.signal_form == "sentinels"
190
+ and not truth.sentinels
191
+ ):
192
+ materialized = materialize_truth(
193
+ truth,
194
+ population,
195
+ seed=seed,
196
+ feature_sets=feature_sets,
197
+ )
198
+ return CorrelatedTruth(
199
+ truth.group_names,
200
+ materialized.direct_features,
201
+ weights=truth.weights,
202
+ signal_form=truth.signal_form,
203
+ )
204
+ return truth
205
+
206
+
207
+ def _slice_outcome(outcome: GeneratedOutcome, rows: slice) -> GeneratedOutcome:
208
+ """Return one role from an outcome generated under a shared mechanism."""
209
+ if isinstance(outcome, BinaryGeneratedOutcome):
210
+ return BinaryGeneratedOutcome(outcome.values[rows], outcome.calibration)
211
+ if isinstance(outcome, ContinuousGeneratedOutcome):
212
+ return ContinuousGeneratedOutcome(outcome.values[rows], outcome.calibration)
213
+ if isinstance(outcome, CountGeneratedOutcome):
214
+ return CountGeneratedOutcome(
215
+ outcome.values[rows], outcome.exposure[rows], outcome.calibration
216
+ )
217
+ if isinstance(outcome, OrdinalGeneratedOutcome):
218
+ return OrdinalGeneratedOutcome(outcome.codes[rows], outcome.categories, outcome.calibration)
219
+ if isinstance(outcome, SurvivalGeneratedOutcome):
220
+ return SurvivalGeneratedOutcome(
221
+ outcome.time[rows],
222
+ outcome.event[rows],
223
+ outcome.latent_event_time[rows],
224
+ outcome.censoring_time[rows],
225
+ outcome.calibration,
226
+ )
227
+ raise TypeError(f"unsupported generated outcome: {type(outcome).__name__}")
228
+
229
+
230
+ def generate_partitioned(
231
+ partition: PopulationPartition,
232
+ *,
233
+ truth: TruthSpec,
234
+ outcome: OutcomeSpec,
235
+ sample_sizes: PartitionSampleSizes,
236
+ seed: int,
237
+ sampling_method: SamplingMethod = "with_replacement",
238
+ feature_sets: FeatureSets | None = None,
239
+ ) -> PartitionedPlasmode:
240
+ """Generate source-disjoint roles under one shared outcome mechanism.
241
+
242
+ Args:
243
+ partition: Prior source partition from ``partition_population``.
244
+ truth: Transferable built-in mechanism; custom callables are unsupported.
245
+ outcome: Observable outcome targets over the combined generated population.
246
+ sample_sizes: Positive training, validation and evaluation counts.
247
+ seed: Non-negative root seed for role sampling and mechanism generation.
248
+ sampling_method: Explicit with- or without-replacement policy within each role.
249
+ feature_sets: Required named groups or modules.
250
+
251
+ Returns:
252
+ Three plasmodes plus original source identities, disjoint across roles.
253
+ Truth identities are frozen on training source rows; sampled roles share
254
+ one signal scaling and outcome calibration. A role's realised rate can
255
+ differ from the combined-population target through sampling variation.
256
+
257
+ Raises:
258
+ ValueError: Invalid roles, unsupported custom truth, or infeasible generation.
259
+ CalibrationError: Outcome targets cannot be represented.
260
+ RealizationError: The combined generated outcome fails a diagnostic check.
261
+ """
262
+ integer(seed, "seed")
263
+ role_seeds = np.random.SeedSequence(seed).spawn(6)
264
+ (
265
+ truth_selection_seed,
266
+ training_seed,
267
+ validation_seed,
268
+ evaluation_seed,
269
+ truth_materialization_seed,
270
+ outcome_seed,
271
+ ) = role_seeds
272
+ frozen_truth = _freeze_partition_truth(
273
+ truth,
274
+ partition.training,
275
+ seed=int(truth_selection_seed.generate_state(1, dtype=np.uint32)[0]),
276
+ feature_sets=feature_sets,
277
+ )
278
+ role_inputs = (
279
+ (
280
+ partition.training,
281
+ partition.training_source_indices,
282
+ sample_sizes.training,
283
+ training_seed,
284
+ ),
285
+ (
286
+ partition.validation,
287
+ partition.validation_source_indices,
288
+ sample_sizes.validation,
289
+ validation_seed,
290
+ ),
291
+ (
292
+ partition.evaluation,
293
+ partition.evaluation_source_indices,
294
+ sample_sizes.evaluation,
295
+ evaluation_seed,
296
+ ),
297
+ )
298
+ sampled = []
299
+ original_lineage: list[NDArray[np.int64]] = []
300
+ for population, source_indices, sample_size, role_seed in role_inputs:
301
+ role_seed_value = int(role_seed.generate_state(1, dtype=np.uint32)[0])
302
+ sample = sample_population(
303
+ population,
304
+ sample_size,
305
+ np.random.default_rng(role_seed_value),
306
+ method=sampling_method,
307
+ )
308
+ sampled.append((sample, role_seed_value))
309
+ original_lineage.append(source_indices[sample.source_indices])
310
+ combined = Population(
311
+ np.vstack(tuple(sample.X for sample, _ in sampled)),
312
+ partition.training.feature_ids,
313
+ )
314
+ materialized = materialize_truth(
315
+ frozen_truth,
316
+ combined,
317
+ seed=int(truth_materialization_seed.generate_state(1, dtype=np.uint32)[0]),
318
+ feature_sets=feature_sets,
319
+ )
320
+ generated_outcome = generate_outcome(
321
+ outcome,
322
+ materialized.signal,
323
+ np.random.default_rng(outcome_seed),
324
+ )
325
+ generated: list[Plasmode] = []
326
+ start = 0
327
+ for sample, role_seed_value in sampled:
328
+ stop = start + sample.X.shape[0]
329
+ rows = slice(start, stop)
330
+ generated.append(
331
+ Plasmode(
332
+ sample.X,
333
+ sample.feature_ids,
334
+ _slice_outcome(generated_outcome, rows),
335
+ replace(materialized, signal=materialized.signal[rows]),
336
+ sample.source_indices,
337
+ role_seed_value,
338
+ sampling_method,
339
+ )
340
+ )
341
+ start = stop
342
+ return PartitionedPlasmode(
343
+ generated[0],
344
+ generated[1],
345
+ generated[2],
346
+ original_lineage[0],
347
+ original_lineage[1],
348
+ original_lineage[2],
349
+ )