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/__init__.py +178 -0
- pyplasmode/_validation.py +29 -0
- pyplasmode/data.py +221 -0
- pyplasmode/evaluate.py +392 -0
- pyplasmode/generate.py +349 -0
- pyplasmode/nominations.py +623 -0
- pyplasmode/outcomes.py +709 -0
- pyplasmode/py.typed +0 -0
- pyplasmode/results.py +172 -0
- pyplasmode/structures.py +123 -0
- pyplasmode/truth.py +510 -0
- pyplasmode-1.0.0.dist-info/METADATA +142 -0
- pyplasmode-1.0.0.dist-info/RECORD +15 -0
- pyplasmode-1.0.0.dist-info/WHEEL +4 -0
- pyplasmode-1.0.0.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Support-aware nominations and truth-blind matched recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from math import comb, factorial
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import ArrayLike, NDArray
|
|
12
|
+
from scipy.stats import hypergeom
|
|
13
|
+
|
|
14
|
+
from pyplasmode._validation import identities, integer
|
|
15
|
+
from pyplasmode.results import FractionalRecovery, MatchedRecovery, OutcomeSpecificity
|
|
16
|
+
from pyplasmode.truth import MaterializedTruth
|
|
17
|
+
|
|
18
|
+
type RecoveryEstimand = Literal["exact", "group", "module", "interaction"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class FractionalNomination:
|
|
23
|
+
"""One tie-aware nomination over a declared positive-support universe."""
|
|
24
|
+
|
|
25
|
+
feature_ids: tuple[str, ...]
|
|
26
|
+
membership: NDArray[np.float64]
|
|
27
|
+
requested_depth: int
|
|
28
|
+
positive_support: tuple[str, ...]
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
"""Require aligned fractional membership without hidden padding."""
|
|
32
|
+
integer(self.requested_depth, "requested_depth", minimum=1)
|
|
33
|
+
identities(self.feature_ids, "feature_ids")
|
|
34
|
+
identities(self.positive_support, "positive_support", allow_empty=True)
|
|
35
|
+
values = np.asarray(self.membership, dtype=np.float64)
|
|
36
|
+
support = set(self.positive_support)
|
|
37
|
+
positions = {feature: index for index, feature in enumerate(self.feature_ids)}
|
|
38
|
+
boundary = values[(values > 0.0) & (values < 1.0)]
|
|
39
|
+
if (
|
|
40
|
+
values.shape != (len(self.feature_ids),)
|
|
41
|
+
or not np.isfinite(values).all()
|
|
42
|
+
or np.any((values < 0.0) | (values > 1.0))
|
|
43
|
+
or self.requested_depth > len(self.feature_ids)
|
|
44
|
+
or not support.issubset(positions)
|
|
45
|
+
or any(
|
|
46
|
+
values[index] > 0.0 and feature not in support
|
|
47
|
+
for index, feature in enumerate(self.feature_ids)
|
|
48
|
+
)
|
|
49
|
+
or (
|
|
50
|
+
boundary.size > 0
|
|
51
|
+
and (
|
|
52
|
+
not np.allclose(boundary, boundary[0], atol=1e-12, rtol=0.0)
|
|
53
|
+
or not np.isclose(float(np.sum(boundary)), round(float(np.sum(boundary))))
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
or not np.isclose(
|
|
57
|
+
float(np.sum(values)),
|
|
58
|
+
min(self.requested_depth, len(self.positive_support)),
|
|
59
|
+
atol=1e-12,
|
|
60
|
+
)
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("fractional nomination is invalid")
|
|
63
|
+
frozen = values.copy()
|
|
64
|
+
frozen.setflags(write=False)
|
|
65
|
+
object.__setattr__(self, "membership", frozen)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def achieved_size(self) -> int:
|
|
69
|
+
"""Return the number of proteins with positive fractional membership."""
|
|
70
|
+
return int(np.count_nonzero(self.membership > 0.0))
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def selected_features(self) -> tuple[str, ...]:
|
|
74
|
+
"""Return the depth-limited features with non-zero selection probability."""
|
|
75
|
+
return tuple(
|
|
76
|
+
feature
|
|
77
|
+
for feature, weight in zip(self.feature_ids, self.membership, strict=True)
|
|
78
|
+
if weight > 0.0
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def membership_budget(self) -> float:
|
|
83
|
+
"""Return the realised protein-membership budget."""
|
|
84
|
+
return float(np.sum(self.membership))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def nomination_from_scores(
|
|
88
|
+
feature_ids: tuple[str, ...],
|
|
89
|
+
scores: ArrayLike,
|
|
90
|
+
*,
|
|
91
|
+
depth: int,
|
|
92
|
+
) -> FractionalNomination:
|
|
93
|
+
"""Construct a positive-support nomination with fractional boundary ties.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
feature_ids: Unique feature identities aligned to scores.
|
|
97
|
+
scores: Finite non-negative method-specific importance scores.
|
|
98
|
+
depth: Requested leading-list protein budget.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Fractional nomination that conserves the budget when support permits.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If identities, scores, or depth are invalid.
|
|
105
|
+
"""
|
|
106
|
+
values = np.asarray(scores, dtype=np.float64)
|
|
107
|
+
integer(depth, "depth", minimum=1)
|
|
108
|
+
identities(feature_ids, "feature_ids")
|
|
109
|
+
if (
|
|
110
|
+
values.shape != (len(feature_ids),)
|
|
111
|
+
or not np.isfinite(values).all()
|
|
112
|
+
or np.any(values < 0.0)
|
|
113
|
+
or depth > len(feature_ids)
|
|
114
|
+
):
|
|
115
|
+
raise ValueError("scores and feature identities are invalid")
|
|
116
|
+
positive = values > 0.0
|
|
117
|
+
support = tuple(feature for feature, keep in zip(feature_ids, positive, strict=True) if keep)
|
|
118
|
+
membership = np.zeros(values.shape, dtype=np.float64)
|
|
119
|
+
if len(support) <= depth:
|
|
120
|
+
membership[positive] = 1.0
|
|
121
|
+
return FractionalNomination(feature_ids, membership, depth, support)
|
|
122
|
+
ordered = np.sort(values[positive])[::-1]
|
|
123
|
+
boundary = float(ordered[depth - 1])
|
|
124
|
+
above = values > boundary
|
|
125
|
+
tied = values == boundary
|
|
126
|
+
membership[above] = 1.0
|
|
127
|
+
remaining = depth - int(np.count_nonzero(above))
|
|
128
|
+
membership[tied] = remaining / int(np.count_nonzero(tied))
|
|
129
|
+
return FractionalNomination(feature_ids, membership, depth, support)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _membership(nomination: FractionalNomination) -> dict[str, float]:
|
|
133
|
+
return dict(zip(nomination.feature_ids, nomination.membership.tolist(), strict=True))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _boundary_selection_probability(
|
|
137
|
+
membership_values: tuple[float, ...],
|
|
138
|
+
target_values: tuple[float, ...],
|
|
139
|
+
*,
|
|
140
|
+
require_all: bool,
|
|
141
|
+
) -> float:
|
|
142
|
+
"""Integrate nonlinear recovery over one uniform boundary tie."""
|
|
143
|
+
if require_all and any(value == 0.0 for value in target_values):
|
|
144
|
+
return 0.0
|
|
145
|
+
if not require_all and any(value == 1.0 for value in target_values):
|
|
146
|
+
return 1.0
|
|
147
|
+
fractional_values = tuple(value for value in membership_values if 0.0 < value < 1.0)
|
|
148
|
+
target_fractional = sum(0.0 < value < 1.0 for value in target_values)
|
|
149
|
+
if target_fractional == 0:
|
|
150
|
+
return 1.0 if require_all else 0.0
|
|
151
|
+
boundary_size = len(fractional_values)
|
|
152
|
+
boundary_slots = round(sum(fractional_values))
|
|
153
|
+
if require_all:
|
|
154
|
+
if boundary_slots < target_fractional:
|
|
155
|
+
return 0.0
|
|
156
|
+
return float(
|
|
157
|
+
comb(boundary_size - target_fractional, boundary_slots - target_fractional)
|
|
158
|
+
/ comb(boundary_size, boundary_slots)
|
|
159
|
+
)
|
|
160
|
+
if boundary_size - target_fractional < boundary_slots:
|
|
161
|
+
return 1.0
|
|
162
|
+
return float(
|
|
163
|
+
1.0
|
|
164
|
+
- comb(boundary_size - target_fractional, boundary_slots)
|
|
165
|
+
/ comb(boundary_size, boundary_slots)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def evaluate_fractional_recovery(
|
|
170
|
+
nomination: FractionalNomination,
|
|
171
|
+
truth: MaterializedTruth,
|
|
172
|
+
*,
|
|
173
|
+
estimand: RecoveryEstimand,
|
|
174
|
+
) -> FractionalRecovery:
|
|
175
|
+
"""Evaluate one mechanism-specific recovery without a composite score.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
nomination: Tie- and support-aware protein membership.
|
|
179
|
+
truth: Materialized generating mechanism on the same feature universe.
|
|
180
|
+
estimand: Exact, group, module, or interaction recovery definition.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Bounded recovery with explicit truth and nomination support.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If the nomination and truth differ or the estimand has no target.
|
|
187
|
+
"""
|
|
188
|
+
if nomination.feature_ids != truth.feature_universe:
|
|
189
|
+
raise ValueError("nomination and truth feature universes differ")
|
|
190
|
+
membership = _membership(nomination)
|
|
191
|
+
if estimand == "exact":
|
|
192
|
+
if not truth.direct_features:
|
|
193
|
+
raise ValueError("exact recovery requires direct truth features")
|
|
194
|
+
value = float(np.mean([membership[feature] for feature in truth.direct_features]))
|
|
195
|
+
truth_support = len(truth.direct_features)
|
|
196
|
+
elif estimand == "group":
|
|
197
|
+
if not truth.admissible_groups:
|
|
198
|
+
raise ValueError("group recovery requires admissible truth groups")
|
|
199
|
+
recovered = [
|
|
200
|
+
_boundary_selection_probability(
|
|
201
|
+
tuple(membership.values()),
|
|
202
|
+
tuple(membership[feature] for feature in group.features),
|
|
203
|
+
require_all=False,
|
|
204
|
+
)
|
|
205
|
+
for group in truth.admissible_groups
|
|
206
|
+
]
|
|
207
|
+
value = float(np.mean(recovered))
|
|
208
|
+
truth_support = len(truth.admissible_groups)
|
|
209
|
+
elif estimand == "module":
|
|
210
|
+
weights = {item.feature: abs(item.weight) for item in truth.weights}
|
|
211
|
+
if not weights or sum(weights.values()) <= 0.0:
|
|
212
|
+
raise ValueError("module recovery requires non-zero truth weights")
|
|
213
|
+
value = sum(membership[feature] * weight for feature, weight in weights.items()) / sum(
|
|
214
|
+
weights.values()
|
|
215
|
+
)
|
|
216
|
+
truth_support = len(weights)
|
|
217
|
+
elif estimand == "interaction":
|
|
218
|
+
if len(truth.direct_features) != 2:
|
|
219
|
+
raise ValueError("interaction recovery requires two direct truth features")
|
|
220
|
+
value = _boundary_selection_probability(
|
|
221
|
+
tuple(membership.values()),
|
|
222
|
+
tuple(membership[feature] for feature in truth.direct_features),
|
|
223
|
+
require_all=True,
|
|
224
|
+
)
|
|
225
|
+
truth_support = 2
|
|
226
|
+
else:
|
|
227
|
+
raise ValueError(f"unsupported recovery estimand: {estimand}")
|
|
228
|
+
return FractionalRecovery(
|
|
229
|
+
estimand,
|
|
230
|
+
float(value),
|
|
231
|
+
truth_support,
|
|
232
|
+
len(nomination.positive_support),
|
|
233
|
+
nomination.requested_depth,
|
|
234
|
+
nomination.achieved_size,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _state_count(weights: list[float], available: int, positive_support: int) -> int:
|
|
239
|
+
counts = Counter(weights)
|
|
240
|
+
selected = len(weights)
|
|
241
|
+
if selected > positive_support or positive_support > available:
|
|
242
|
+
raise ValueError("matched stratum support geometry is infeasible")
|
|
243
|
+
denominator = factorial(available - positive_support) * factorial(positive_support - selected)
|
|
244
|
+
for count in counts.values():
|
|
245
|
+
denominator *= factorial(count)
|
|
246
|
+
return factorial(available) // denominator
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _geometry(
|
|
250
|
+
nomination: FractionalNomination, strata: tuple[str, ...]
|
|
251
|
+
) -> tuple[
|
|
252
|
+
dict[str, tuple[int, ...]],
|
|
253
|
+
dict[str, int],
|
|
254
|
+
dict[str, tuple[float, ...]],
|
|
255
|
+
]:
|
|
256
|
+
if len(strata) != len(nomination.feature_ids) or any(not item for item in strata):
|
|
257
|
+
raise ValueError("strata must identify every feature")
|
|
258
|
+
available: dict[str, list[int]] = defaultdict(list)
|
|
259
|
+
support_counts: Counter[str] = Counter()
|
|
260
|
+
weights: dict[str, list[float]] = defaultdict(list)
|
|
261
|
+
positive = set(nomination.positive_support)
|
|
262
|
+
for index, (stratum, weight) in enumerate(zip(strata, nomination.membership, strict=True)):
|
|
263
|
+
available[stratum].append(index)
|
|
264
|
+
if nomination.feature_ids[index] in positive:
|
|
265
|
+
support_counts[stratum] += 1
|
|
266
|
+
if weight > 0.0:
|
|
267
|
+
weights[stratum].append(float(weight))
|
|
268
|
+
return (
|
|
269
|
+
{key: tuple(value) for key, value in available.items()},
|
|
270
|
+
{key: support_counts[key] for key in available},
|
|
271
|
+
{key: tuple(value) for key, value in weights.items()},
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _sample_nomination(
|
|
276
|
+
nomination: FractionalNomination,
|
|
277
|
+
available: dict[str, tuple[int, ...]],
|
|
278
|
+
support_counts: dict[str, int],
|
|
279
|
+
weights: dict[str, tuple[float, ...]],
|
|
280
|
+
rng: np.random.Generator,
|
|
281
|
+
) -> FractionalNomination:
|
|
282
|
+
membership = np.zeros(len(nomination.feature_ids), dtype=np.float64)
|
|
283
|
+
positive_indices: list[int] = []
|
|
284
|
+
for key in sorted(available):
|
|
285
|
+
values = np.asarray(weights.get(key, ()), dtype=np.float64)
|
|
286
|
+
support_count = support_counts[key]
|
|
287
|
+
if support_count == 0:
|
|
288
|
+
continue
|
|
289
|
+
support = np.asarray(
|
|
290
|
+
rng.choice(available[key], size=support_count, replace=False),
|
|
291
|
+
dtype=np.int64,
|
|
292
|
+
)
|
|
293
|
+
positive_indices.extend(int(index) for index in support)
|
|
294
|
+
if values.size:
|
|
295
|
+
chosen = rng.choice(support, size=values.size, replace=False)
|
|
296
|
+
membership[chosen] = rng.permutation(values)
|
|
297
|
+
positive_set = set(positive_indices)
|
|
298
|
+
return FractionalNomination(
|
|
299
|
+
nomination.feature_ids,
|
|
300
|
+
membership,
|
|
301
|
+
nomination.requested_depth,
|
|
302
|
+
tuple(
|
|
303
|
+
feature for index, feature in enumerate(nomination.feature_ids) if index in positive_set
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _expected_membership(
|
|
309
|
+
index: int,
|
|
310
|
+
*,
|
|
311
|
+
available: dict[str, tuple[int, ...]],
|
|
312
|
+
weights: dict[str, tuple[float, ...]],
|
|
313
|
+
) -> float:
|
|
314
|
+
"""Return marginal selection probability under the matched assignment law."""
|
|
315
|
+
for key, indices in available.items():
|
|
316
|
+
if index in indices:
|
|
317
|
+
return float(sum(weights.get(key, ())) / len(indices))
|
|
318
|
+
raise RuntimeError("feature index is absent from matched-reference strata")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _no_target_selection_probability(
|
|
322
|
+
target_indices: tuple[int, ...],
|
|
323
|
+
*,
|
|
324
|
+
available: dict[str, tuple[int, ...]],
|
|
325
|
+
weights: dict[str, tuple[float, ...]],
|
|
326
|
+
) -> float:
|
|
327
|
+
"""Return the analytic probability that no target enters the final tied panel."""
|
|
328
|
+
targets = set(target_indices)
|
|
329
|
+
boundary_weights = tuple(
|
|
330
|
+
weight for values in weights.values() for weight in values if 0.0 < weight < 1.0
|
|
331
|
+
)
|
|
332
|
+
boundary_count = len(boundary_weights)
|
|
333
|
+
boundary_slots = round(sum(boundary_weights))
|
|
334
|
+
target_boundary_distribution = np.asarray([1.0], dtype=np.float64)
|
|
335
|
+
for key in sorted(available):
|
|
336
|
+
indices = available[key]
|
|
337
|
+
population = len(indices)
|
|
338
|
+
target_count = sum(index in targets for index in indices)
|
|
339
|
+
values = weights.get(key, ())
|
|
340
|
+
full_count = sum(weight == 1.0 for weight in values)
|
|
341
|
+
stratum_boundary_count = sum(0.0 < weight < 1.0 for weight in values)
|
|
342
|
+
no_full_probability = float(hypergeom.pmf(0, population, target_count, full_count))
|
|
343
|
+
maximum_target_boundary = min(
|
|
344
|
+
target_count,
|
|
345
|
+
stratum_boundary_count,
|
|
346
|
+
population - full_count,
|
|
347
|
+
)
|
|
348
|
+
boundary_targets = np.arange(maximum_target_boundary + 1, dtype=np.int64)
|
|
349
|
+
distribution = (
|
|
350
|
+
np.zeros(boundary_targets.size, dtype=np.float64)
|
|
351
|
+
if no_full_probability == 0.0
|
|
352
|
+
else no_full_probability
|
|
353
|
+
* np.asarray(
|
|
354
|
+
hypergeom.pmf(
|
|
355
|
+
boundary_targets,
|
|
356
|
+
population - full_count,
|
|
357
|
+
target_count,
|
|
358
|
+
stratum_boundary_count,
|
|
359
|
+
),
|
|
360
|
+
dtype=np.float64,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
target_boundary_distribution = np.convolve(
|
|
364
|
+
target_boundary_distribution,
|
|
365
|
+
distribution,
|
|
366
|
+
)
|
|
367
|
+
if boundary_slots == 0:
|
|
368
|
+
probability = float(target_boundary_distribution[0])
|
|
369
|
+
else:
|
|
370
|
+
target_counts = np.arange(target_boundary_distribution.size, dtype=np.int64)
|
|
371
|
+
avoid_boundary_targets = np.asarray(
|
|
372
|
+
hypergeom.pmf(0, boundary_count, target_counts, boundary_slots),
|
|
373
|
+
dtype=np.float64,
|
|
374
|
+
)
|
|
375
|
+
probability = float(target_boundary_distribution @ avoid_boundary_targets)
|
|
376
|
+
if not np.isfinite(probability) or probability < -1e-12 or probability > 1.0 + 1e-12:
|
|
377
|
+
raise RuntimeError("analytic matched-reference probability is invalid")
|
|
378
|
+
return float(np.clip(probability, 0.0, 1.0))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _expected_matched_recovery(
|
|
382
|
+
truth: MaterializedTruth,
|
|
383
|
+
*,
|
|
384
|
+
estimand: RecoveryEstimand,
|
|
385
|
+
available: dict[str, tuple[int, ...]],
|
|
386
|
+
weights: dict[str, tuple[float, ...]],
|
|
387
|
+
positions: dict[str, int],
|
|
388
|
+
) -> float:
|
|
389
|
+
"""Calculate the matched-reference expectation without simulation error."""
|
|
390
|
+
if estimand == "exact":
|
|
391
|
+
expected = np.asarray(
|
|
392
|
+
[
|
|
393
|
+
_expected_membership(
|
|
394
|
+
positions[feature],
|
|
395
|
+
available=available,
|
|
396
|
+
weights=weights,
|
|
397
|
+
)
|
|
398
|
+
for feature in truth.direct_features
|
|
399
|
+
],
|
|
400
|
+
dtype=np.float64,
|
|
401
|
+
)
|
|
402
|
+
return float(np.mean(expected))
|
|
403
|
+
if estimand == "module":
|
|
404
|
+
target_weights = np.asarray(
|
|
405
|
+
[abs(item.weight) for item in truth.weights],
|
|
406
|
+
dtype=np.float64,
|
|
407
|
+
)
|
|
408
|
+
expected = np.asarray(
|
|
409
|
+
[
|
|
410
|
+
_expected_membership(
|
|
411
|
+
positions[item.feature],
|
|
412
|
+
available=available,
|
|
413
|
+
weights=weights,
|
|
414
|
+
)
|
|
415
|
+
for item in truth.weights
|
|
416
|
+
],
|
|
417
|
+
dtype=np.float64,
|
|
418
|
+
)
|
|
419
|
+
return float(np.average(expected, weights=target_weights))
|
|
420
|
+
if estimand == "group":
|
|
421
|
+
recovered = tuple(
|
|
422
|
+
1.0
|
|
423
|
+
- _no_target_selection_probability(
|
|
424
|
+
tuple(positions[feature] for feature in group.features),
|
|
425
|
+
available=available,
|
|
426
|
+
weights=weights,
|
|
427
|
+
)
|
|
428
|
+
for group in truth.admissible_groups
|
|
429
|
+
)
|
|
430
|
+
return float(np.mean(recovered))
|
|
431
|
+
if estimand == "interaction":
|
|
432
|
+
first, second = (positions[feature] for feature in truth.direct_features)
|
|
433
|
+
neither_first = _no_target_selection_probability(
|
|
434
|
+
(first,),
|
|
435
|
+
available=available,
|
|
436
|
+
weights=weights,
|
|
437
|
+
)
|
|
438
|
+
neither_second = _no_target_selection_probability(
|
|
439
|
+
(second,),
|
|
440
|
+
available=available,
|
|
441
|
+
weights=weights,
|
|
442
|
+
)
|
|
443
|
+
neither = _no_target_selection_probability(
|
|
444
|
+
(first, second),
|
|
445
|
+
available=available,
|
|
446
|
+
weights=weights,
|
|
447
|
+
)
|
|
448
|
+
return float(np.clip(1.0 - neither_first - neither_second + neither, 0.0, 1.0))
|
|
449
|
+
raise ValueError(f"unsupported recovery estimand: {estimand}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def sample_matched_nominations(
|
|
453
|
+
nomination: FractionalNomination,
|
|
454
|
+
*,
|
|
455
|
+
strata: tuple[str, ...],
|
|
456
|
+
draws: int,
|
|
457
|
+
seed: int,
|
|
458
|
+
) -> tuple[FractionalNomination, ...]:
|
|
459
|
+
"""Draw truth-blind nominations with the observed support geometry.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
nomination: Observed support- and tie-aware nomination.
|
|
463
|
+
strata: Outcome-blind geometry stratum for every feature.
|
|
464
|
+
draws: Positive number of independently sampled nominations.
|
|
465
|
+
seed: Non-negative random seed.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
Matched nominations preserving positive-support size, membership
|
|
469
|
+
weights, realised cardinality, boundary ties, and stratum counts while
|
|
470
|
+
randomising identities over the complete eligible feature universe.
|
|
471
|
+
|
|
472
|
+
Raises:
|
|
473
|
+
ValueError: If geometry, draw count, or seed is invalid.
|
|
474
|
+
"""
|
|
475
|
+
integer(draws, "draws", minimum=1)
|
|
476
|
+
integer(seed, "seed")
|
|
477
|
+
available, support_counts, weights = _geometry(nomination, strata)
|
|
478
|
+
if any(
|
|
479
|
+
len(weights.get(key, ())) > support_counts[key] or support_counts[key] > len(indices)
|
|
480
|
+
for key, indices in available.items()
|
|
481
|
+
):
|
|
482
|
+
raise ValueError("matched nomination geometry is not feasible")
|
|
483
|
+
rng = np.random.default_rng(seed)
|
|
484
|
+
return tuple(
|
|
485
|
+
_sample_nomination(nomination, available, support_counts, weights, rng)
|
|
486
|
+
for _ in range(draws)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def evaluate_matched_recovery(
|
|
491
|
+
nomination: FractionalNomination,
|
|
492
|
+
truth: MaterializedTruth,
|
|
493
|
+
*,
|
|
494
|
+
estimand: RecoveryEstimand,
|
|
495
|
+
strata: tuple[str, ...],
|
|
496
|
+
) -> MatchedRecovery:
|
|
497
|
+
"""Calibrate observed recovery against truth-blind matched nominations.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
nomination: Observed support- and tie-aware nomination.
|
|
501
|
+
truth: Materialized truth used only by the recovery evaluator.
|
|
502
|
+
estimand: Mechanism-specific recovery definition.
|
|
503
|
+
strata: Outcome-blind geometry stratum for every feature.
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
Observed, expected, and chance-adjusted recovery under the analytic
|
|
507
|
+
matched assignment law.
|
|
508
|
+
|
|
509
|
+
Raises:
|
|
510
|
+
ValueError: If geometry or truth are invalid.
|
|
511
|
+
"""
|
|
512
|
+
observed = evaluate_fractional_recovery(nomination, truth, estimand=estimand)
|
|
513
|
+
available, support_counts, weights = _geometry(nomination, strata)
|
|
514
|
+
positions = {feature: index for index, feature in enumerate(nomination.feature_ids)}
|
|
515
|
+
panel_count = 1
|
|
516
|
+
for key in sorted(available):
|
|
517
|
+
stratum_weights = list(weights.get(key, ()))
|
|
518
|
+
panel_count *= _state_count(
|
|
519
|
+
stratum_weights,
|
|
520
|
+
len(available[key]),
|
|
521
|
+
support_counts[key],
|
|
522
|
+
)
|
|
523
|
+
expected = _expected_matched_recovery(
|
|
524
|
+
truth,
|
|
525
|
+
estimand=estimand,
|
|
526
|
+
available=available,
|
|
527
|
+
weights=weights,
|
|
528
|
+
positions=positions,
|
|
529
|
+
)
|
|
530
|
+
return MatchedRecovery(
|
|
531
|
+
estimand,
|
|
532
|
+
"analytic",
|
|
533
|
+
nomination.requested_depth,
|
|
534
|
+
nomination.achieved_size,
|
|
535
|
+
observed.value,
|
|
536
|
+
expected,
|
|
537
|
+
observed.value - expected,
|
|
538
|
+
panel_count,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _recovery_target_features(
|
|
543
|
+
truth: MaterializedTruth,
|
|
544
|
+
estimand: RecoveryEstimand,
|
|
545
|
+
) -> frozenset[str]:
|
|
546
|
+
"""Return every feature that can receive credit under one recovery estimand."""
|
|
547
|
+
if estimand in {"exact", "interaction"}:
|
|
548
|
+
features = truth.direct_features
|
|
549
|
+
elif estimand == "group":
|
|
550
|
+
features = tuple(feature for group in truth.admissible_groups for feature in group.features)
|
|
551
|
+
elif estimand == "module":
|
|
552
|
+
features = tuple(item.feature for item in truth.weights)
|
|
553
|
+
else: # pragma: no cover - exhaustive over RecoveryEstimand
|
|
554
|
+
raise ValueError(f"unsupported recovery estimand: {estimand}")
|
|
555
|
+
if not features:
|
|
556
|
+
raise ValueError("specificity requires a non-empty recovery target")
|
|
557
|
+
return frozenset(features)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def evaluate_outcome_specificity(
|
|
561
|
+
nomination: FractionalNomination,
|
|
562
|
+
own_truth: MaterializedTruth,
|
|
563
|
+
foreign_truths: tuple[MaterializedTruth, ...],
|
|
564
|
+
*,
|
|
565
|
+
estimand: RecoveryEstimand,
|
|
566
|
+
strata: tuple[str, ...],
|
|
567
|
+
) -> OutcomeSpecificity:
|
|
568
|
+
"""Compare own-truth recovery with recovery of disjoint foreign truths.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
nomination: Outcome-derived, tie-aware nomination to evaluate unchanged.
|
|
572
|
+
own_truth: Generating truth for the outcome used to obtain the nomination.
|
|
573
|
+
foreign_truths: Other truth identities of the same mechanism and size.
|
|
574
|
+
estimand: Mechanism-specific exact, group, module, or interaction recovery.
|
|
575
|
+
strata: Outcome-blind matching stratum for every feature.
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
Own adjusted recovery, mean foreign adjusted recovery, and their difference.
|
|
579
|
+
|
|
580
|
+
Raises:
|
|
581
|
+
ValueError: If truths differ in universe or mechanism, are absent, or overlap.
|
|
582
|
+
"""
|
|
583
|
+
if not foreign_truths:
|
|
584
|
+
raise ValueError("specificity requires at least one foreign truth")
|
|
585
|
+
all_truths = (own_truth, *foreign_truths)
|
|
586
|
+
if any(
|
|
587
|
+
truth.kind != own_truth.kind or truth.feature_universe != own_truth.feature_universe
|
|
588
|
+
for truth in foreign_truths
|
|
589
|
+
):
|
|
590
|
+
raise ValueError("specificity truths differ in mechanism or feature universe")
|
|
591
|
+
supports = tuple(_recovery_target_features(truth, estimand) for truth in all_truths)
|
|
592
|
+
target_sizes = (
|
|
593
|
+
{len(truth.admissible_groups) for truth in all_truths}
|
|
594
|
+
if estimand == "group"
|
|
595
|
+
else {len(support) for support in supports}
|
|
596
|
+
)
|
|
597
|
+
if len(target_sizes) != 1:
|
|
598
|
+
raise ValueError("specificity truths must have the same target size")
|
|
599
|
+
if any(left & right for index, left in enumerate(supports) for right in supports[index + 1 :]):
|
|
600
|
+
raise ValueError("specificity truth targets are not disjoint")
|
|
601
|
+
own = evaluate_matched_recovery(
|
|
602
|
+
nomination,
|
|
603
|
+
own_truth,
|
|
604
|
+
estimand=estimand,
|
|
605
|
+
strata=strata,
|
|
606
|
+
).chance_adjusted_recovery
|
|
607
|
+
foreign = tuple(
|
|
608
|
+
evaluate_matched_recovery(
|
|
609
|
+
nomination,
|
|
610
|
+
truth,
|
|
611
|
+
estimand=estimand,
|
|
612
|
+
strata=strata,
|
|
613
|
+
).chance_adjusted_recovery
|
|
614
|
+
for truth in foreign_truths
|
|
615
|
+
)
|
|
616
|
+
mean_foreign = float(np.mean(foreign))
|
|
617
|
+
return OutcomeSpecificity(
|
|
618
|
+
estimand,
|
|
619
|
+
own,
|
|
620
|
+
mean_foreign,
|
|
621
|
+
own - mean_foreign,
|
|
622
|
+
len(foreign),
|
|
623
|
+
)
|