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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
@@ -0,0 +1,3 @@
1
+ """Pluggable building blocks: DE methods and feature spaces."""
2
+
3
+ from . import de, spaces