scperteval 0.1.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.
- scperteval/__init__.py +64 -0
- scperteval/__main__.py +3 -0
- scperteval/api.py +486 -0
- scperteval/blocks/__init__.py +3 -0
- scperteval/blocks/de.py +262 -0
- scperteval/blocks/spaces.py +205 -0
- scperteval/calibrators.py +55 -0
- scperteval/cli.py +249 -0
- scperteval/context.py +357 -0
- scperteval/dataset.py +106 -0
- scperteval/io.py +120 -0
- scperteval/predictions.py +65 -0
- scperteval/protocols/__init__.py +3 -0
- scperteval/protocols/metrics.py +423 -0
- scperteval/protocols/resolve.py +99 -0
- scperteval/protocols/table.py +111 -0
- scperteval/py.typed +0 -0
- scperteval/reference.py +50 -0
- scperteval/registry.py +63 -0
- scperteval/runner.py +239 -0
- scperteval/sources.py +132 -0
- scperteval/types.py +181 -0
- scperteval-0.1.0.dist-info/METADATA +192 -0
- scperteval-0.1.0.dist-info/RECORD +27 -0
- scperteval-0.1.0.dist-info/WHEEL +4 -0
- scperteval-0.1.0.dist-info/entry_points.txt +2 -0
- scperteval-0.1.0.dist-info/licenses/LICENSE +21 -0
scperteval/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Evaluation Protocols for Perturbation Studies."""
|
|
2
|
+
|
|
3
|
+
import os as _os
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
# Pin BLAS/OMP threads BEFORE anything imports numpy/torch — this must stay above the API
|
|
7
|
+
# re-export below, which (lazily) pulls in the heavy numeric stack.
|
|
8
|
+
for _v in (
|
|
9
|
+
"OMP_NUM_THREADS",
|
|
10
|
+
"OPENBLAS_NUM_THREADS",
|
|
11
|
+
"MKL_NUM_THREADS",
|
|
12
|
+
"NUMEXPR_NUM_THREADS",
|
|
13
|
+
"VECLIB_MAXIMUM_THREADS",
|
|
14
|
+
):
|
|
15
|
+
_os.environ.setdefault(_v, "1")
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # let type checkers and IDEs see the lazily re-exported names (no runtime import)
|
|
18
|
+
from .api import (
|
|
19
|
+
DatasetDEResults,
|
|
20
|
+
EvalResult,
|
|
21
|
+
Prepared,
|
|
22
|
+
calibrate,
|
|
23
|
+
de,
|
|
24
|
+
prepare,
|
|
25
|
+
score,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__: str
|
|
29
|
+
|
|
30
|
+
#: The public Python API, re-exported from :mod:`scperteval.api`.
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DatasetDEResults",
|
|
33
|
+
"EvalResult",
|
|
34
|
+
"Prepared",
|
|
35
|
+
"__version__",
|
|
36
|
+
"calibrate",
|
|
37
|
+
"de",
|
|
38
|
+
"prepare",
|
|
39
|
+
"score",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def __getattr__(name: str):
|
|
44
|
+
"""Lazily resolve public names so ``import scperteval`` stays cheap.
|
|
45
|
+
|
|
46
|
+
The API (and its numeric dependencies: numpy/torch/geomloss/sklearn) load only on first
|
|
47
|
+
access to a public symbol; ``scperteval.__version__`` never triggers that import.
|
|
48
|
+
"""
|
|
49
|
+
if name == "__version__":
|
|
50
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
return version("scperteval")
|
|
54
|
+
except PackageNotFoundError: # running from a source tree without an installed dist
|
|
55
|
+
return "0.0.0+unknown"
|
|
56
|
+
if name in __all__:
|
|
57
|
+
from . import api
|
|
58
|
+
|
|
59
|
+
return getattr(api, name)
|
|
60
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def __dir__():
|
|
64
|
+
return sorted(__all__)
|
scperteval/__main__.py
ADDED
scperteval/api.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""The native Python API — evaluate one protocol, or compute one DE method, at a time.
|
|
2
|
+
|
|
3
|
+
Usage is always **prepare, then run**: build a reusable :func:`prepare` handle for a dataset (read
|
|
4
|
+
+ index once, precompute the declared protocols' spaces), then call :func:`calibrate` /
|
|
5
|
+
:func:`score` / :func:`de` on that handle — each evaluates a single protocol or DE method and
|
|
6
|
+
returns in-memory results (pandas). Many calls share the handle's dataset and caches (no reload),
|
|
7
|
+
and are safe to run concurrently: each call builds its own lightweight context over the shared,
|
|
8
|
+
thread-safe cache, so nothing is mutated across calls.
|
|
9
|
+
|
|
10
|
+
Re-exported at the package root, e.g. ``scperteval.calibrate(...)``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, replace
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Literal, NamedTuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from . import io
|
|
24
|
+
from .blocks.de import DE_METHODS
|
|
25
|
+
from .context import CacheStore, Context
|
|
26
|
+
from .dataset import Dataset
|
|
27
|
+
from .predictions import PredictionSet
|
|
28
|
+
from .protocols.resolve import resolve_protocols
|
|
29
|
+
from .runner import compute_de, run_all
|
|
30
|
+
from .sources import SOURCES
|
|
31
|
+
from .types import Protocol, RunConfig
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING: # annotation-only; keeps ``import scperteval`` from eagerly importing anndata
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
|
|
36
|
+
from anndata import AnnData
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DatasetDEResults",
|
|
40
|
+
"EvalResult",
|
|
41
|
+
"Prepared",
|
|
42
|
+
"calibrate",
|
|
43
|
+
"de",
|
|
44
|
+
"prepare",
|
|
45
|
+
"score",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
#: The calibration outputs selectable from :func:`calibrate` (closed set).
|
|
49
|
+
CalibratorName = Literal["drf", "bds"]
|
|
50
|
+
#: The DE backends selectable from :func:`de` / ``de_method`` — mirrors the ``DE_METHODS`` registry's
|
|
51
|
+
#: built-ins (kept in sync by ``tests/test_api.py::test_de_method_literal_matches_registry``).
|
|
52
|
+
DEMethodName = Literal["t-test", "MWU", "t-test_overestim_var"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------- result types
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class EvalResult:
|
|
60
|
+
"""Result of evaluating one protocol on one dataset.
|
|
61
|
+
|
|
62
|
+
Attributes
|
|
63
|
+
----------
|
|
64
|
+
aggregate : dict[str, float]
|
|
65
|
+
The protocol's summary statistics — ``{"mean": …, "median": …}`` for ``drf``/``score``,
|
|
66
|
+
``{"bds": …}`` for ``bds``.
|
|
67
|
+
per_perturbation : pandas.DataFrame
|
|
68
|
+
One row per perturbation (raw control values + the calibrated score, or the raw metric) —
|
|
69
|
+
the same layout the CLI writes to CSV.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
aggregate: dict[str, float]
|
|
73
|
+
per_perturbation: pd.DataFrame
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
col = self.per_perturbation.get("perturbation")
|
|
77
|
+
n = col.nunique() if col is not None else 0
|
|
78
|
+
return f"EvalResult(aggregate={self.aggregate}, perturbations={n})"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DatasetDEResults(NamedTuple):
|
|
82
|
+
"""Per-gene differential expression across the whole dataset, for one method.
|
|
83
|
+
|
|
84
|
+
Both frames are indexed by perturbation with genes as columns.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
#: Test statistic per (perturbation, gene).
|
|
88
|
+
statistic: pd.DataFrame
|
|
89
|
+
#: Benjamini-Hochberg adjusted p-value per (perturbation, gene).
|
|
90
|
+
pvalue_adj: pd.DataFrame
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------- prepared handle
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Prepared:
|
|
97
|
+
"""A reusable, prepared dataset: build once with :func:`prepare`, pass to many verb calls.
|
|
98
|
+
|
|
99
|
+
Holds the read-and-indexed dataset (resident in memory), a shared thread-safe cache, and the
|
|
100
|
+
immutable prepare-time configuration. Each :func:`calibrate` / :func:`score` / :func:`de` call
|
|
101
|
+
builds its own lightweight context over this handle, so the handle itself is never mutated —
|
|
102
|
+
sequential *and* concurrent calls against one handle are safe. Treat it as opaque; its
|
|
103
|
+
internals are not part of the public API.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
__slots__ = ("_cfg", "_ds", "_sources", "_store")
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
ds: Dataset,
|
|
111
|
+
store: CacheStore,
|
|
112
|
+
cfg: RunConfig,
|
|
113
|
+
sources: dict[str, tuple[Callable, dict]] | None = None,
|
|
114
|
+
):
|
|
115
|
+
self._ds = ds
|
|
116
|
+
self._store = store
|
|
117
|
+
self._cfg = cfg
|
|
118
|
+
self._sources = sources or {} # per-handle runtime user sources ({name: (callable, meta)})
|
|
119
|
+
|
|
120
|
+
def _run_context(self, **overrides) -> Context:
|
|
121
|
+
"""A fresh per-call context sharing this handle's dataset + cache, with per-call config."""
|
|
122
|
+
return Context(self._ds, replace(self._cfg, **overrides), store=self._store, user_sources=self._sources)
|
|
123
|
+
|
|
124
|
+
def __repr__(self) -> str:
|
|
125
|
+
return f"Prepared(name={Path(self._cfg.dataset).stem!r}, perturbations={len(self._ds.perturbations)})"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- helpers
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _display_name(dataset, name: str | None) -> str:
|
|
132
|
+
"""The label threaded into ``cfg.dataset`` (drives summary headers and output filenames)."""
|
|
133
|
+
if name is not None:
|
|
134
|
+
return name
|
|
135
|
+
if isinstance(dataset, (str, Path)):
|
|
136
|
+
return str(dataset)
|
|
137
|
+
return "dataset"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _to_dataset(dataset, cfg: RunConfig) -> Dataset:
|
|
141
|
+
"""Build a :class:`~scperteval.dataset.Dataset` from a path or an in-memory AnnData."""
|
|
142
|
+
if isinstance(dataset, (str, Path)):
|
|
143
|
+
return Dataset.load(str(dataset), cfg)
|
|
144
|
+
return Dataset(dataset, cfg) # an AnnData (referenced, never mutated)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _to_predictions(predictions, ds: Dataset, cfg: RunConfig) -> PredictionSet:
|
|
148
|
+
"""Build a :class:`~scperteval.predictions.PredictionSet` from a path or an AnnData."""
|
|
149
|
+
if isinstance(predictions, (str, Path)):
|
|
150
|
+
return PredictionSet.load(str(predictions), ds, cfg)
|
|
151
|
+
return PredictionSet(predictions, ds, cfg)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _require_prepared(prepared, verb: str) -> None:
|
|
155
|
+
if not isinstance(prepared, Prepared):
|
|
156
|
+
raise TypeError(
|
|
157
|
+
f"{verb}() takes a handle from prepare(); got {type(prepared).__name__}. "
|
|
158
|
+
f"Call prepare(dataset, protocols) first, then pass the result here."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _single_protocol(protocol: str):
|
|
163
|
+
"""Resolve one protocol spec to exactly one concrete protocol (error otherwise)."""
|
|
164
|
+
if not isinstance(protocol, str):
|
|
165
|
+
raise TypeError(f"protocol must be a single protocol name (str), not {type(protocol).__name__}")
|
|
166
|
+
protos = resolve_protocols([protocol])
|
|
167
|
+
if len(protos) != 1:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"the API evaluates one protocol per call; {protocol!r} resolves to {len(protos)} "
|
|
170
|
+
f"protocols (pass a single name, e.g. 'pearson_ctrl' or 'mse_top_k=30')"
|
|
171
|
+
)
|
|
172
|
+
return protos[0]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _check_de_method(method: str) -> None:
|
|
176
|
+
if method not in DE_METHODS:
|
|
177
|
+
raise ValueError(f"unknown DE method {method!r}; available: {', '.join(DE_METHODS.names())}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _stamp() -> str:
|
|
181
|
+
# Microsecond resolution: verbs share one handle and may run concurrently, so two writes of the
|
|
182
|
+
# same protocol to one out_dir must get distinct filenames rather than silently overwriting.
|
|
183
|
+
return datetime.now().strftime("%Y-%m-%dT%H%M%S%f")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _const_source(array: np.ndarray):
|
|
187
|
+
"""A source callable that returns its stored constant array for any perturbation."""
|
|
188
|
+
|
|
189
|
+
def fn(ctx, pert):
|
|
190
|
+
return array
|
|
191
|
+
|
|
192
|
+
return fn
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _validate_sources(sources, ds: Dataset) -> dict[str, tuple[Callable, dict]]:
|
|
196
|
+
"""Validate + register the ``prepare(sources=...)`` user sources for one handle.
|
|
197
|
+
|
|
198
|
+
Each ``{name: array}`` becomes a per-handle source (``cacheable=False``): a 1-D ``(G,)`` array
|
|
199
|
+
is a ``"centroid"`` and a 2-D ``(n, G)`` array is ``"cells"``. Values are copied to a contiguous
|
|
200
|
+
``float64`` array (never aliasing the caller's memory). ``G`` is checked against the dataset's
|
|
201
|
+
gene count, but **not** gene order — see :func:`prepare`.
|
|
202
|
+
"""
|
|
203
|
+
if not sources:
|
|
204
|
+
return {}
|
|
205
|
+
n_genes = len(ds.var_names)
|
|
206
|
+
out: dict[str, tuple[Callable, dict]] = {}
|
|
207
|
+
for name, array in sources.items():
|
|
208
|
+
if name == "auto":
|
|
209
|
+
raise ValueError("user source name 'auto' is reserved (the control-override sentinel); rename it")
|
|
210
|
+
if name in SOURCES:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"user source {name!r} shadows a built-in source ({', '.join(SOURCES.names())}); rename it"
|
|
213
|
+
)
|
|
214
|
+
if not isinstance(array, np.ndarray):
|
|
215
|
+
raise TypeError(f"user source {name!r} must be a numpy array, got {type(array).__name__}")
|
|
216
|
+
if not (np.issubdtype(array.dtype, np.floating) or np.issubdtype(array.dtype, np.integer)):
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"user source {name!r} must be a real-valued numeric array (integer or floating), "
|
|
219
|
+
f"got dtype {array.dtype}"
|
|
220
|
+
)
|
|
221
|
+
if array.ndim == 1:
|
|
222
|
+
provides, g = "centroid", array.shape[0]
|
|
223
|
+
elif array.ndim == 2:
|
|
224
|
+
provides, g = "cells", array.shape[1]
|
|
225
|
+
else:
|
|
226
|
+
raise ValueError(f"user source {name!r} must be 1-D (centroid) or 2-D (cells), got shape {array.shape}")
|
|
227
|
+
if g != n_genes:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"user source {name!r} has {g} genes but the dataset has {n_genes}; "
|
|
230
|
+
f"columns must be in adata.var_names order"
|
|
231
|
+
)
|
|
232
|
+
if not np.isfinite(array).all():
|
|
233
|
+
raise ValueError(f"user source {name!r} has non-finite values (NaN/inf); all entries must be finite")
|
|
234
|
+
data = np.array(array, dtype=np.float64, order="C") # contiguous float64 copy, never aliasing caller memory
|
|
235
|
+
out[name] = (_const_source(data), {"provides": provides, "cacheable": False})
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _apply_center_on(proto: Protocol, center_on: str, ctx: Context) -> Protocol:
|
|
240
|
+
"""Mint a named centering variant ``<base>_center_<center_on>`` from an un-centred centroid protocol.
|
|
241
|
+
|
|
242
|
+
Centering is protocol identity, so a custom-vector baseline is recorded in the protocol name
|
|
243
|
+
rather than silently overriding a catalog protocol. ``center_on`` must name a registered
|
|
244
|
+
centroid source (user or built-in).
|
|
245
|
+
"""
|
|
246
|
+
if proto.representation != "centroid":
|
|
247
|
+
raise ValueError(f"center_on only applies to centroid protocols; {proto.name!r} is {proto.representation!r}")
|
|
248
|
+
if proto.centering is not None:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"center_on requires an un-centered protocol; {proto.name!r} already centers on {proto.centering!r}"
|
|
251
|
+
)
|
|
252
|
+
if not ctx.has_source(center_on):
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"center_on source {center_on!r} is not registered; valid sources: {', '.join(ctx.source_names())}"
|
|
255
|
+
)
|
|
256
|
+
provides = ctx.source_meta(center_on).get("provides")
|
|
257
|
+
if provides != "centroid":
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"center_on source {center_on!r} provides {provides!r}, but a centering baseline must be a centroid (1-D)"
|
|
260
|
+
)
|
|
261
|
+
return replace(proto, centering=center_on, name=f"{proto.name}_center_{center_on}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --------------------------------------------------------------------------- public functions
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def prepare(
|
|
268
|
+
dataset: str | Path | AnnData,
|
|
269
|
+
protocols: str | list[str],
|
|
270
|
+
*,
|
|
271
|
+
subsample: int = 8192,
|
|
272
|
+
seed: int = 42,
|
|
273
|
+
min_cells: int = 30,
|
|
274
|
+
perturbation_key: str = "perturbation",
|
|
275
|
+
control_label: str = "control",
|
|
276
|
+
workers: int = 0,
|
|
277
|
+
name: str | None = None,
|
|
278
|
+
sources: dict[str, np.ndarray] | None = None,
|
|
279
|
+
) -> Prepared:
|
|
280
|
+
"""Prepare a dataset for evaluation — the required first step.
|
|
281
|
+
|
|
282
|
+
Reads and indexes the dataset once (held in memory) and **precomputes the declared protocols'
|
|
283
|
+
feature spaces** (e.g. PCA, fit once at the largest requested ``k``) plus the shared reference
|
|
284
|
+
sample, deterministically. The returned :class:`Prepared` handle is then passed to
|
|
285
|
+
:func:`calibrate` / :func:`score` / :func:`de`, which reuse its dataset and caches — including
|
|
286
|
+
across concurrent calls. Differential expression is **not** precomputed here; it is computed at
|
|
287
|
+
the verb call under that call's DE method (and cached per method on the handle).
|
|
288
|
+
|
|
289
|
+
Parameters
|
|
290
|
+
----------
|
|
291
|
+
dataset : str or pathlib.Path or anndata.AnnData
|
|
292
|
+
A preprocessed ``.h5ad`` path, or an in-memory AnnData. If an AnnData, the handle holds a
|
|
293
|
+
reference to it (not a copy) — do not mutate it while the handle is in use, or results
|
|
294
|
+
become inconsistent.
|
|
295
|
+
protocols : str or list of str
|
|
296
|
+
The protocol(s) you intend to evaluate — used to precompute their spaces up front (pass
|
|
297
|
+
``"all"`` for the whole catalog, or ``[]`` if you only need :func:`de` / no spaces). A verb
|
|
298
|
+
may still run a protocol not declared here; its space is then computed on first use.
|
|
299
|
+
subsample, seed, min_cells, perturbation_key, control_label, workers, name
|
|
300
|
+
Dataset/run knobs fixed for the handle; see :class:`~scperteval.types.RunConfig`.
|
|
301
|
+
sources : dict[str, numpy.ndarray], optional
|
|
302
|
+
Runtime **user sources** registered on this handle (never on the global registry, so they
|
|
303
|
+
don't leak across handles). Each ``{name: array}`` becomes a reusable, constant-across-
|
|
304
|
+
perturbations source: a 1-D ``(G,)`` array is a centroid, a 2-D ``(n_cells, G)`` array is a
|
|
305
|
+
cell population. Use them as controls (``negative=``/``positive=`` on :func:`calibrate`) or
|
|
306
|
+
as a centering baseline (``center_on=`` on :func:`calibrate`/:func:`score`). Arrays must be
|
|
307
|
+
numeric and all-finite, with ``G`` equal to the dataset's gene count. **Gene-order caveat:**
|
|
308
|
+
columns are assumed to be in ``adata.var_names`` order — validation checks the gene *count*
|
|
309
|
+
but cannot check the *order*, so a mis-ordered vector silently compares the wrong genes.
|
|
310
|
+
|
|
311
|
+
Returns
|
|
312
|
+
-------
|
|
313
|
+
Prepared
|
|
314
|
+
An opaque, reusable handle.
|
|
315
|
+
"""
|
|
316
|
+
specs = [protocols] if isinstance(protocols, str) else list(protocols)
|
|
317
|
+
protos = resolve_protocols(specs) if specs else []
|
|
318
|
+
cfg = RunConfig(
|
|
319
|
+
dataset=_display_name(dataset, name),
|
|
320
|
+
protocols=[p.name for p in protos],
|
|
321
|
+
subsample=subsample,
|
|
322
|
+
seed=seed,
|
|
323
|
+
min_cells=min_cells,
|
|
324
|
+
perturbation_key=perturbation_key,
|
|
325
|
+
control_label=control_label,
|
|
326
|
+
workers=workers,
|
|
327
|
+
)
|
|
328
|
+
ds = _to_dataset(dataset, cfg)
|
|
329
|
+
user_sources = _validate_sources(sources, ds) # fail fast on bad user sources, before warming
|
|
330
|
+
ctx = Context(ds, cfg)
|
|
331
|
+
ctx.warm(protos) # precompute declared spaces + reference (method-independent); no DE
|
|
332
|
+
return Prepared(ctx.ds, ctx._store, cfg, user_sources)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def calibrate(
|
|
336
|
+
prepared: Prepared,
|
|
337
|
+
protocol: str,
|
|
338
|
+
*,
|
|
339
|
+
de_method: DEMethodName = "t-test",
|
|
340
|
+
calibrator: CalibratorName = "drf",
|
|
341
|
+
positive: str = "auto",
|
|
342
|
+
negative: str = "auto",
|
|
343
|
+
center_on: str | None = None,
|
|
344
|
+
out_dir: str | Path | None = None,
|
|
345
|
+
) -> EvalResult:
|
|
346
|
+
"""Calibrate one protocol against the built-in positive/negative controls (DRF or BDS).
|
|
347
|
+
|
|
348
|
+
Parameters
|
|
349
|
+
----------
|
|
350
|
+
prepared : Prepared
|
|
351
|
+
A handle from :func:`prepare`.
|
|
352
|
+
protocol : str
|
|
353
|
+
A single protocol spec — a name (``"pearson_ctrl"``) or a tunable one (``"mse_top_k=30"``).
|
|
354
|
+
de_method : str, optional
|
|
355
|
+
DE backend for any DE-dependent part of the protocol (default ``"t-test"``).
|
|
356
|
+
calibrator : {"drf", "bds"}, optional
|
|
357
|
+
Which calibrator to apply (default ``"drf"``).
|
|
358
|
+
positive, negative : str, optional
|
|
359
|
+
Override the protocol's control sources (``"auto"`` defers to the protocol). A registered
|
|
360
|
+
user source (from ``prepare(sources=...)``) is accepted here.
|
|
361
|
+
center_on : str, optional
|
|
362
|
+
Center the (un-centred, centroid) protocol on a named centroid source's baseline. Because
|
|
363
|
+
centering is protocol identity, this **mints a named variant** ``<protocol>_center_<name>``
|
|
364
|
+
(never a silent override); the variant name flows into ``EvalResult`` and any CSV. ``name``
|
|
365
|
+
may be a user source or a built-in centroid (e.g. ``"all_perturbed_mean"``).
|
|
366
|
+
out_dir : str or pathlib.Path, optional
|
|
367
|
+
If given, also write the per-perturbation CSV there (as the CLI does).
|
|
368
|
+
|
|
369
|
+
Returns
|
|
370
|
+
-------
|
|
371
|
+
EvalResult
|
|
372
|
+
``.aggregate`` (the protocol's summary stats) and ``.per_perturbation`` (the detail table).
|
|
373
|
+
"""
|
|
374
|
+
_require_prepared(prepared, "calibrate")
|
|
375
|
+
if calibrator not in ("drf", "bds"):
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"calibrate calibrator must be 'drf' or 'bds', not {calibrator!r} (use score() for predictions)"
|
|
378
|
+
)
|
|
379
|
+
_check_de_method(de_method)
|
|
380
|
+
proto = _single_protocol(protocol)
|
|
381
|
+
ctx = prepared._run_context(
|
|
382
|
+
protocols=[proto.name],
|
|
383
|
+
de_method=de_method,
|
|
384
|
+
calibrator=calibrator,
|
|
385
|
+
positive=positive,
|
|
386
|
+
negative=negative,
|
|
387
|
+
out_dir=str(out_dir) if out_dir is not None else "results",
|
|
388
|
+
)
|
|
389
|
+
if center_on is not None:
|
|
390
|
+
proto = _apply_center_on(proto, center_on, ctx)
|
|
391
|
+
ctx.cfg.protocols = [proto.name] # keep summary/CSV labels in sync with the minted variant
|
|
392
|
+
aggregates, rows, _ = run_all(ctx.cfg, [proto], ctx)
|
|
393
|
+
if out_dir is not None:
|
|
394
|
+
io.write_rows(ctx.cfg, rows, _stamp())
|
|
395
|
+
return EvalResult(aggregate=aggregates[proto.name], per_perturbation=io.rows_frame(ctx.cfg, rows))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def score(
|
|
399
|
+
prepared: Prepared,
|
|
400
|
+
protocol: str,
|
|
401
|
+
predictions: str | Path | AnnData,
|
|
402
|
+
*,
|
|
403
|
+
de_method: DEMethodName = "t-test",
|
|
404
|
+
center_on: str | None = None,
|
|
405
|
+
out_dir: str | Path | None = None,
|
|
406
|
+
) -> EvalResult:
|
|
407
|
+
"""Score model predictions against ground truth for one protocol.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
prepared : Prepared
|
|
412
|
+
A handle from :func:`prepare` (the ground-truth dataset).
|
|
413
|
+
protocol : str
|
|
414
|
+
A single protocol spec (see :func:`calibrate`).
|
|
415
|
+
predictions : str or pathlib.Path or anndata.AnnData
|
|
416
|
+
Predicted cells — the same genes and perturbation labels as the dataset.
|
|
417
|
+
de_method : str, optional
|
|
418
|
+
DE backend for any DE-dependent part of the protocol (default ``"t-test"``).
|
|
419
|
+
center_on : str, optional
|
|
420
|
+
Center on a named centroid source's baseline, minting a ``<protocol>_center_<name>`` variant
|
|
421
|
+
(see :func:`calibrate`).
|
|
422
|
+
out_dir : str or pathlib.Path, optional
|
|
423
|
+
If given, also write the per-perturbation CSV there.
|
|
424
|
+
|
|
425
|
+
Returns
|
|
426
|
+
-------
|
|
427
|
+
EvalResult
|
|
428
|
+
``.aggregate`` (mean/median raw metric) and ``.per_perturbation`` (the detail table).
|
|
429
|
+
"""
|
|
430
|
+
_require_prepared(prepared, "score")
|
|
431
|
+
_check_de_method(de_method)
|
|
432
|
+
proto = _single_protocol(protocol)
|
|
433
|
+
ctx = prepared._run_context(
|
|
434
|
+
protocols=[proto.name],
|
|
435
|
+
de_method=de_method,
|
|
436
|
+
calibrator="score",
|
|
437
|
+
truth="gt_all_cells",
|
|
438
|
+
out_dir=str(out_dir) if out_dir is not None else "results",
|
|
439
|
+
)
|
|
440
|
+
if center_on is not None:
|
|
441
|
+
proto = _apply_center_on(proto, center_on, ctx)
|
|
442
|
+
ctx.cfg.protocols = [proto.name] # keep summary/CSV labels in sync with the minted variant
|
|
443
|
+
ctx.predictions = _to_predictions(predictions, ctx.ds, ctx.cfg)
|
|
444
|
+
aggregates, rows, _ = run_all(ctx.cfg, [proto], ctx)
|
|
445
|
+
if out_dir is not None:
|
|
446
|
+
io.write_rows(ctx.cfg, rows, _stamp())
|
|
447
|
+
return EvalResult(aggregate=aggregates[proto.name], per_perturbation=io.rows_frame(ctx.cfg, rows))
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def de(
|
|
451
|
+
prepared: Prepared,
|
|
452
|
+
method: DEMethodName = "t-test",
|
|
453
|
+
*,
|
|
454
|
+
out_dir: str | Path | None = None,
|
|
455
|
+
) -> DatasetDEResults:
|
|
456
|
+
"""Compute per-gene differential expression (ground truth vs all-perturbed) for one method.
|
|
457
|
+
|
|
458
|
+
Parameters
|
|
459
|
+
----------
|
|
460
|
+
prepared : Prepared
|
|
461
|
+
A handle from :func:`prepare`.
|
|
462
|
+
method : str, optional
|
|
463
|
+
The DE backend (default ``"t-test"``). Different methods reuse the same prepared dataset,
|
|
464
|
+
cached separately — no reload.
|
|
465
|
+
out_dir : str or pathlib.Path, optional
|
|
466
|
+
If given, also write the HDF5 export there (as the CLI does).
|
|
467
|
+
|
|
468
|
+
Returns
|
|
469
|
+
-------
|
|
470
|
+
DatasetDEResults
|
|
471
|
+
``.statistic`` and ``.pvalue_adj`` DataFrames (perturbations × genes).
|
|
472
|
+
"""
|
|
473
|
+
_require_prepared(prepared, "de")
|
|
474
|
+
_check_de_method(method)
|
|
475
|
+
ctx = prepared._run_context(de_method=method, out_dir=str(out_dir) if out_dir is not None else "results")
|
|
476
|
+
ctx._ensure_reference_sums()
|
|
477
|
+
statistic, pvalue_adj = compute_de(ctx)
|
|
478
|
+
perts = list(ctx.perturbations)
|
|
479
|
+
genes = [str(g) for g in ctx.ds.var_names]
|
|
480
|
+
result = DatasetDEResults(
|
|
481
|
+
statistic=pd.DataFrame(statistic, index=perts, columns=genes),
|
|
482
|
+
pvalue_adj=pd.DataFrame(pvalue_adj, index=perts, columns=genes),
|
|
483
|
+
)
|
|
484
|
+
if out_dir is not None:
|
|
485
|
+
io.write_de(ctx.cfg, ctx.ds.var_names, ctx.perturbations, {method: (statistic, pvalue_adj)}, _stamp())
|
|
486
|
+
return result
|