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
pyplasmode/evaluate.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Pure biomarker recovery, prediction and stability evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from itertools import combinations
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike, NDArray
|
|
10
|
+
from scipy.stats import spearmanr, t
|
|
11
|
+
from sklearn.dummy import DummyRegressor
|
|
12
|
+
from sklearn.impute import SimpleImputer
|
|
13
|
+
from sklearn.linear_model import LinearRegression
|
|
14
|
+
from sklearn.metrics import (
|
|
15
|
+
average_precision_score,
|
|
16
|
+
log_loss,
|
|
17
|
+
r2_score,
|
|
18
|
+
roc_auc_score,
|
|
19
|
+
root_mean_squared_error,
|
|
20
|
+
)
|
|
21
|
+
from sklearn.pipeline import make_pipeline
|
|
22
|
+
|
|
23
|
+
from pyplasmode._validation import indices as integer_indices
|
|
24
|
+
from pyplasmode._validation import integer
|
|
25
|
+
from pyplasmode.results import (
|
|
26
|
+
GraphRegionAtDepth,
|
|
27
|
+
GraphRegionEvaluation,
|
|
28
|
+
GroupAtDepth,
|
|
29
|
+
GroupEvaluation,
|
|
30
|
+
ModuleAtDepth,
|
|
31
|
+
ModuleEvaluation,
|
|
32
|
+
PanelEvaluation,
|
|
33
|
+
PredictionEvaluation,
|
|
34
|
+
RankingAtDepth,
|
|
35
|
+
RankingEvaluation,
|
|
36
|
+
RankStabilityEvaluation,
|
|
37
|
+
ReplicateSummary,
|
|
38
|
+
SignalReconstructionEvaluation,
|
|
39
|
+
)
|
|
40
|
+
from pyplasmode.structures import FeatureGraph
|
|
41
|
+
from pyplasmode.truth import MaterializedTruth
|
|
42
|
+
|
|
43
|
+
type PredictionMetric = Literal["roc_auc", "average_precision", "log_loss", "r2", "rmse"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ranking(ranking: tuple[str, ...], universe: tuple[str, ...]) -> tuple[str, ...]:
|
|
47
|
+
if len(set(ranking)) != len(ranking):
|
|
48
|
+
raise ValueError("ranking contains duplicate feature identities")
|
|
49
|
+
universe_set = set(universe)
|
|
50
|
+
unknown = tuple(feature for feature in ranking if feature not in universe_set)
|
|
51
|
+
if unknown:
|
|
52
|
+
raise ValueError(f"ranking contains unknown feature identities: {unknown}")
|
|
53
|
+
if len(ranking) != len(universe) or set(ranking) != set(universe):
|
|
54
|
+
raise ValueError("ranking must be a complete permutation of the feature universe")
|
|
55
|
+
return tuple(ranking)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _depths(depths: tuple[int, ...], feature_count: int) -> tuple[int, ...]:
|
|
59
|
+
for depth in depths:
|
|
60
|
+
integer(depth, "depth", minimum=1)
|
|
61
|
+
if not depths or tuple(sorted(set(depths))) != depths:
|
|
62
|
+
raise ValueError("depths must be non-empty, unique and strictly increasing")
|
|
63
|
+
if any(isinstance(depth, bool) or depth <= 0 or depth > feature_count for depth in depths):
|
|
64
|
+
raise ValueError("each depth must be between one and the feature count")
|
|
65
|
+
return depths
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def evaluate_ranking(
|
|
69
|
+
ranking: tuple[str, ...],
|
|
70
|
+
truth: MaterializedTruth,
|
|
71
|
+
*,
|
|
72
|
+
depths: tuple[int, ...],
|
|
73
|
+
) -> RankingEvaluation:
|
|
74
|
+
"""Evaluate exact generating-feature recovery at declared leading depths."""
|
|
75
|
+
ordered = _ranking(ranking, truth.feature_universe)
|
|
76
|
+
checked_depths = _depths(depths, len(ordered))
|
|
77
|
+
target = set(truth.direct_features)
|
|
78
|
+
if not target:
|
|
79
|
+
raise ValueError("this truth has no exact generating-feature target")
|
|
80
|
+
first = min(
|
|
81
|
+
(index + 1 for index, feature in enumerate(ordered) if feature in target),
|
|
82
|
+
default=None,
|
|
83
|
+
)
|
|
84
|
+
rows: list[RankingAtDepth] = []
|
|
85
|
+
for depth in checked_depths:
|
|
86
|
+
selected = set(ordered[:depth])
|
|
87
|
+
true_positives = len(selected & target)
|
|
88
|
+
precision = true_positives / len(selected)
|
|
89
|
+
rows.append(
|
|
90
|
+
RankingAtDepth(
|
|
91
|
+
depth=depth,
|
|
92
|
+
selected_count=len(selected),
|
|
93
|
+
true_positive_count=true_positives,
|
|
94
|
+
target_count=len(target),
|
|
95
|
+
recall=true_positives / len(target),
|
|
96
|
+
precision=precision,
|
|
97
|
+
false_discovery_fraction=1.0 - precision,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return RankingEvaluation(first, tuple(rows))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def evaluate_groups(
|
|
104
|
+
ranking: tuple[str, ...],
|
|
105
|
+
truth: MaterializedTruth,
|
|
106
|
+
*,
|
|
107
|
+
depths: tuple[int, ...],
|
|
108
|
+
) -> GroupEvaluation:
|
|
109
|
+
"""Evaluate recovery of at least one admissible feature per declared group."""
|
|
110
|
+
ordered = _ranking(ranking, truth.feature_universe)
|
|
111
|
+
checked_depths = _depths(depths, len(ordered))
|
|
112
|
+
groups = truth.admissible_groups
|
|
113
|
+
if not groups:
|
|
114
|
+
raise ValueError("this truth has no admissible substitute groups")
|
|
115
|
+
rows = []
|
|
116
|
+
for depth in checked_depths:
|
|
117
|
+
selected = set(ordered[:depth])
|
|
118
|
+
recovered = sum(bool(selected & set(group.features)) for group in groups)
|
|
119
|
+
rows.append(GroupAtDepth(depth, recovered, len(groups), recovered / len(groups)))
|
|
120
|
+
return GroupEvaluation(tuple(rows))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def evaluate_module(
|
|
124
|
+
ranking: tuple[str, ...],
|
|
125
|
+
truth: MaterializedTruth,
|
|
126
|
+
*,
|
|
127
|
+
depths: tuple[int, ...],
|
|
128
|
+
) -> ModuleEvaluation:
|
|
129
|
+
"""Evaluate absolute-weight coverage of a distributed truth module."""
|
|
130
|
+
ordered = _ranking(ranking, truth.feature_universe)
|
|
131
|
+
checked_depths = _depths(depths, len(ordered))
|
|
132
|
+
weights = {item.feature: abs(item.weight) for item in truth.weights}
|
|
133
|
+
if not weights:
|
|
134
|
+
raise ValueError("this truth has no weighted module target")
|
|
135
|
+
total = sum(weights.values())
|
|
136
|
+
rows = []
|
|
137
|
+
for depth in checked_depths:
|
|
138
|
+
selected = set(ordered[:depth])
|
|
139
|
+
recovered = selected & set(weights)
|
|
140
|
+
rows.append(
|
|
141
|
+
ModuleAtDepth(
|
|
142
|
+
depth,
|
|
143
|
+
len(recovered),
|
|
144
|
+
len(weights),
|
|
145
|
+
sum(weights[feature] for feature in recovered) / total,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return ModuleEvaluation(tuple(rows))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def evaluate_graph_region(
|
|
152
|
+
ranking: tuple[str, ...],
|
|
153
|
+
graph: FeatureGraph,
|
|
154
|
+
*,
|
|
155
|
+
region: tuple[str, ...],
|
|
156
|
+
depths: tuple[int, ...],
|
|
157
|
+
) -> GraphRegionEvaluation:
|
|
158
|
+
"""Evaluate node and induced-edge recovery for a declared graph region."""
|
|
159
|
+
ordered = _ranking(ranking, graph.nodes)
|
|
160
|
+
checked_depths = _depths(depths, len(ordered))
|
|
161
|
+
region_set = set(region)
|
|
162
|
+
if not region_set or not region_set <= set(graph.nodes):
|
|
163
|
+
raise ValueError("graph region must be a non-empty subset of graph nodes")
|
|
164
|
+
region_edges = {
|
|
165
|
+
frozenset((edge.source, edge.target))
|
|
166
|
+
for edge in graph.edges
|
|
167
|
+
if edge.source in region_set and edge.target in region_set
|
|
168
|
+
}
|
|
169
|
+
rows = []
|
|
170
|
+
for depth in checked_depths:
|
|
171
|
+
selected = set(ordered[:depth])
|
|
172
|
+
recovered_edges = sum(edge <= selected for edge in region_edges)
|
|
173
|
+
edge_coverage = recovered_edges / len(region_edges) if region_edges else 0.0
|
|
174
|
+
rows.append(
|
|
175
|
+
GraphRegionAtDepth(
|
|
176
|
+
depth,
|
|
177
|
+
len(selected & region_set) / len(region_set),
|
|
178
|
+
edge_coverage,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return GraphRegionEvaluation(tuple(rows))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _indices(indices: ArrayLike, size: int, name: str) -> NDArray[np.int64]:
|
|
185
|
+
values = integer_indices(indices, name)
|
|
186
|
+
if values.ndim != 1 or not values.size or np.any((values < 0) | (values >= size)):
|
|
187
|
+
raise ValueError(f"{name} must contain valid row indices")
|
|
188
|
+
if len(set(values.tolist())) != values.size:
|
|
189
|
+
raise ValueError(f"{name} must not contain duplicates")
|
|
190
|
+
return values
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def evaluate_signal_reconstruction(
|
|
194
|
+
X: ArrayLike,
|
|
195
|
+
feature_ids: tuple[str, ...],
|
|
196
|
+
*,
|
|
197
|
+
selected_features: tuple[str, ...],
|
|
198
|
+
signal: ArrayLike,
|
|
199
|
+
development_indices: ArrayLike,
|
|
200
|
+
evaluation_indices: ArrayLike,
|
|
201
|
+
) -> SignalReconstructionEvaluation:
|
|
202
|
+
"""Fit a maintained linear model and evaluate held-out latent-signal reconstruction."""
|
|
203
|
+
matrix = np.asarray(X, dtype=np.float64)
|
|
204
|
+
target = np.asarray(signal, dtype=np.float64)
|
|
205
|
+
if matrix.ndim != 2 or target.shape != (matrix.shape[0],):
|
|
206
|
+
raise ValueError("X and signal dimensions are incompatible")
|
|
207
|
+
if len(feature_ids) != matrix.shape[1] or len(set(feature_ids)) != len(feature_ids):
|
|
208
|
+
raise ValueError("feature_ids must uniquely identify every X column")
|
|
209
|
+
if len(set(selected_features)) != len(selected_features):
|
|
210
|
+
raise ValueError("selected_features must be unique")
|
|
211
|
+
positions = {feature: index for index, feature in enumerate(feature_ids)}
|
|
212
|
+
unknown = tuple(feature for feature in selected_features if feature not in positions)
|
|
213
|
+
if unknown:
|
|
214
|
+
raise ValueError(f"selected_features contain unknown identities: {unknown}")
|
|
215
|
+
columns = [positions[feature] for feature in selected_features]
|
|
216
|
+
selected = matrix[:, columns] if columns else np.zeros((matrix.shape[0], 1), dtype=np.float64)
|
|
217
|
+
if np.isinf(selected).any() or not np.isfinite(target).all():
|
|
218
|
+
raise ValueError("signal reconstruction contains infinite values or a non-finite signal")
|
|
219
|
+
development = _indices(development_indices, matrix.shape[0], "development_indices")
|
|
220
|
+
evaluation = _indices(evaluation_indices, matrix.shape[0], "evaluation_indices")
|
|
221
|
+
if set(development.tolist()) & set(evaluation.tolist()):
|
|
222
|
+
raise ValueError("development and evaluation indices must be disjoint")
|
|
223
|
+
model = (
|
|
224
|
+
make_pipeline(SimpleImputer(strategy="median"), LinearRegression())
|
|
225
|
+
if columns
|
|
226
|
+
else DummyRegressor(strategy="mean")
|
|
227
|
+
).fit(selected[development], target[development])
|
|
228
|
+
predictions = model.predict(selected[evaluation])
|
|
229
|
+
return SignalReconstructionEvaluation(
|
|
230
|
+
len(columns),
|
|
231
|
+
evaluation.size,
|
|
232
|
+
evaluate_prediction(target[evaluation], predictions, metric="r2").value,
|
|
233
|
+
evaluate_prediction(target[evaluation], predictions, metric="rmse").value,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def evaluate_prediction(
|
|
238
|
+
target: ArrayLike, prediction: ArrayLike, *, metric: PredictionMetric
|
|
239
|
+
) -> PredictionEvaluation:
|
|
240
|
+
"""Evaluate supplied predictions with one maintained standard metric."""
|
|
241
|
+
observed = np.asarray(target, dtype=np.float64)
|
|
242
|
+
estimated = np.asarray(prediction, dtype=np.float64)
|
|
243
|
+
if (
|
|
244
|
+
observed.ndim != 1
|
|
245
|
+
or not observed.size
|
|
246
|
+
or estimated.shape != observed.shape
|
|
247
|
+
or not np.isfinite(observed).all()
|
|
248
|
+
or not np.isfinite(estimated).all()
|
|
249
|
+
):
|
|
250
|
+
raise ValueError("target and prediction must be finite aligned one-dimensional arrays")
|
|
251
|
+
if (
|
|
252
|
+
metric in {"roc_auc", "average_precision", "log_loss"}
|
|
253
|
+
and not np.isin(observed, (0.0, 1.0)).all()
|
|
254
|
+
):
|
|
255
|
+
raise ValueError("classification targets must be binary zero/one labels")
|
|
256
|
+
if metric == "roc_auc":
|
|
257
|
+
if np.unique(observed).size != 2:
|
|
258
|
+
raise ValueError("ROC AUC requires both target classes")
|
|
259
|
+
value = roc_auc_score(observed, estimated)
|
|
260
|
+
elif metric == "average_precision":
|
|
261
|
+
if not np.any(observed == 1.0):
|
|
262
|
+
raise ValueError("average precision requires a positive target")
|
|
263
|
+
value = average_precision_score(observed, estimated)
|
|
264
|
+
elif metric == "log_loss":
|
|
265
|
+
if np.any((estimated < 0.0) | (estimated > 1.0)):
|
|
266
|
+
raise ValueError("log loss requires probabilities in [0, 1]")
|
|
267
|
+
value = log_loss(observed, estimated, labels=[0, 1])
|
|
268
|
+
elif metric == "r2":
|
|
269
|
+
if observed.size < 2 or np.ptp(observed) == 0.0:
|
|
270
|
+
raise ValueError("R-squared requires at least two observations and a varying target")
|
|
271
|
+
value = r2_score(observed, estimated)
|
|
272
|
+
elif metric == "rmse":
|
|
273
|
+
value = root_mean_squared_error(observed, estimated)
|
|
274
|
+
else:
|
|
275
|
+
raise ValueError(f"unsupported prediction metric: {metric}")
|
|
276
|
+
if not np.isfinite(value):
|
|
277
|
+
raise ValueError(f"{metric} is not finite for the supplied observations")
|
|
278
|
+
return PredictionEvaluation(metric, float(value), observed.size)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def evaluate_panel(
|
|
282
|
+
target: ArrayLike,
|
|
283
|
+
reference_prediction: ArrayLike,
|
|
284
|
+
panel_prediction: ArrayLike,
|
|
285
|
+
*,
|
|
286
|
+
metric: PredictionMetric,
|
|
287
|
+
) -> PanelEvaluation:
|
|
288
|
+
"""Compare a panel's supplied predictions with reference-model predictions."""
|
|
289
|
+
reference = evaluate_prediction(target, reference_prediction, metric=metric)
|
|
290
|
+
panel = evaluate_prediction(target, panel_prediction, metric=metric)
|
|
291
|
+
return PanelEvaluation(
|
|
292
|
+
metric,
|
|
293
|
+
reference.value,
|
|
294
|
+
panel.value,
|
|
295
|
+
panel.value - reference.value,
|
|
296
|
+
reference.sample_count,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _rank_positions(ranking: tuple[str, ...]) -> dict[str, int]:
|
|
301
|
+
return {feature: index for index, feature in enumerate(ranking, start=1)}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _rbo(left: tuple[str, ...], right: tuple[str, ...], persistence: float) -> float:
|
|
305
|
+
overlap = 0
|
|
306
|
+
weighted = 0.0
|
|
307
|
+
left_seen: set[str] = set()
|
|
308
|
+
right_seen: set[str] = set()
|
|
309
|
+
for depth, (left_item, right_item) in enumerate(zip(left, right, strict=True), start=1):
|
|
310
|
+
overlap += int(left_item in right_seen)
|
|
311
|
+
left_seen.add(left_item)
|
|
312
|
+
overlap += int(right_item in left_seen)
|
|
313
|
+
right_seen.add(right_item)
|
|
314
|
+
agreement = overlap / depth
|
|
315
|
+
weighted += agreement * persistence ** (depth - 1)
|
|
316
|
+
return float((1.0 - persistence) * weighted + (overlap / len(left)) * persistence ** len(left))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def rank_stability(
|
|
320
|
+
rankings: tuple[tuple[str, ...], ...],
|
|
321
|
+
*,
|
|
322
|
+
depth: int,
|
|
323
|
+
persistence: float = 0.9,
|
|
324
|
+
) -> RankStabilityEvaluation:
|
|
325
|
+
"""Summarize pairwise leading-set, Spearman and rank-biased overlap stability."""
|
|
326
|
+
if len(rankings) < 2:
|
|
327
|
+
raise ValueError("rank_stability requires at least two rankings")
|
|
328
|
+
universe = tuple(rankings[0])
|
|
329
|
+
if len(universe) < 2:
|
|
330
|
+
raise ValueError("rank_stability requires at least two features for Spearman agreement")
|
|
331
|
+
checked = tuple(_ranking(ranking, universe) for ranking in rankings)
|
|
332
|
+
_depths((depth,), len(universe))
|
|
333
|
+
if not 0.0 < persistence < 1.0:
|
|
334
|
+
raise ValueError("persistence must be between zero and one")
|
|
335
|
+
jaccards: list[float] = []
|
|
336
|
+
intersections: list[float] = []
|
|
337
|
+
correlations: list[float] = []
|
|
338
|
+
rbos: list[float] = []
|
|
339
|
+
for left, right in combinations(checked, 2):
|
|
340
|
+
left_top = set(left[:depth])
|
|
341
|
+
right_top = set(right[:depth])
|
|
342
|
+
intersection = len(left_top & right_top)
|
|
343
|
+
intersections.append(float(intersection))
|
|
344
|
+
jaccards.append(intersection / len(left_top | right_top))
|
|
345
|
+
left_positions = _rank_positions(left)
|
|
346
|
+
right_positions = _rank_positions(right)
|
|
347
|
+
correlations.append(
|
|
348
|
+
float(
|
|
349
|
+
spearmanr(
|
|
350
|
+
[left_positions[feature] for feature in universe],
|
|
351
|
+
[right_positions[feature] for feature in universe],
|
|
352
|
+
).statistic
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
rbos.append(_rbo(left, right, persistence))
|
|
356
|
+
expected = depth**2 / len(universe)
|
|
357
|
+
mean_intersection = float(np.mean(intersections))
|
|
358
|
+
return RankStabilityEvaluation(
|
|
359
|
+
len(checked),
|
|
360
|
+
len(universe),
|
|
361
|
+
depth,
|
|
362
|
+
float(np.mean(jaccards)),
|
|
363
|
+
mean_intersection,
|
|
364
|
+
expected,
|
|
365
|
+
mean_intersection - expected,
|
|
366
|
+
float(np.mean(correlations)),
|
|
367
|
+
float(np.mean(rbos)),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def summarize_replicates(values: ArrayLike, *, failure_count: int = 0) -> ReplicateSummary:
|
|
372
|
+
"""Summarize replicate estimates with a 95% Monte Carlo t interval."""
|
|
373
|
+
estimates = np.asarray(values, dtype=np.float64)
|
|
374
|
+
if estimates.ndim != 1 or estimates.size < 2 or not np.isfinite(estimates).all():
|
|
375
|
+
raise ValueError("uncertainty requires at least two finite replicate values")
|
|
376
|
+
if isinstance(failure_count, bool) or not isinstance(failure_count, int) or failure_count < 0:
|
|
377
|
+
raise ValueError("failure_count must be a non-negative integer")
|
|
378
|
+
mean = float(np.mean(estimates))
|
|
379
|
+
standard_deviation = float(np.std(estimates, ddof=1))
|
|
380
|
+
standard_error = standard_deviation / np.sqrt(estimates.size)
|
|
381
|
+
critical = float(t.ppf(0.975, df=estimates.size - 1))
|
|
382
|
+
low = mean - critical * standard_error
|
|
383
|
+
high = mean + critical * standard_error
|
|
384
|
+
return ReplicateSummary(
|
|
385
|
+
estimates.size,
|
|
386
|
+
failure_count,
|
|
387
|
+
mean,
|
|
388
|
+
standard_deviation,
|
|
389
|
+
standard_error,
|
|
390
|
+
low,
|
|
391
|
+
high,
|
|
392
|
+
)
|