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/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
+ )