crispyx 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {crispyx-0.2.0/src/crispyx.egg-info → crispyx-0.2.2}/PKG-INFO +2 -3
- {crispyx-0.2.0 → crispyx-0.2.2}/pyproject.toml +5 -3
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/__init__.py +1 -6
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_checkpoint.py +144 -53
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_disk.py +4 -5
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_irls.py +82 -7
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_kernels.py +94 -158
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_memory.py +165 -138
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_messages.py +21 -3
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_namespaces.py +9 -15
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_statistics.py +6 -20
- crispyx-0.2.2/src/crispyx/_version.py +5 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/batch.py +0 -6
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/data.py +621 -245
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/de.py +1094 -2020
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/glm.py +195 -207
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/hvg.py +1 -3
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/pseudobulk.py +81 -81
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/qc.py +105 -148
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/residual.py +4 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/sample.py +4 -0
- {crispyx-0.2.0 → crispyx-0.2.2/src/crispyx.egg-info}/PKG-INFO +2 -3
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx.egg-info/SOURCES.txt +3 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx.egg-info/requires.txt +1 -4
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_cgroup_memory_budget.py +68 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_concat_h5ad.py +2 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_convert_format.py +147 -1
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_data_helpers.py +15 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_de_resume.py +104 -66
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_disk_dispatch.py +4 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_downsample_counts.py +2 -0
- crispyx-0.2.2/tests/test_in_memory_input.py +124 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_irls.py +50 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_memory_dispatch.py +25 -49
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_messages.py +35 -9
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_nb_glm.py +98 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_pickle.py +16 -1
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_pseudobulk_memory.py +37 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_qc_parity.py +4 -2
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_result_provenance.py +18 -25
- crispyx-0.2.2/tests/test_standardize.py +102 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_stream_on_fast_axis.py +17 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_temp_file_cleanup.py +1 -1
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_wilcoxon_dispatch.py +54 -94
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_wilcoxon_labels.py +3 -2
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_wilcoxon_stratified.py +3 -1
- {crispyx-0.2.0 → crispyx-0.2.2}/LICENSE +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/README.md +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/setup.cfg +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_grouping.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_h5codec.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_preflight.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_provenance.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_size_factors.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/dimred.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/plotting.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/profiling.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx.egg-info/dependency_links.txt +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx.egg-info/top_level.txt +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_batch_process.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_compress_h5ad.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_de_edge_cases.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_de_lowexpr_filter.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_dimred.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_glm_structured.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_groupby_alias.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_hvg.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_normalisation.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_normalized_effects.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_normalized_effects_se.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_obs_writes.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_plotting.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_preflight_dispatch.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_profiling.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_pseudobulk_batch.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_pseudobulk_profiles.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_rank_genes_groups_outputs.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_read_backed.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_residual_effects.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_scanpy_parity.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_streaming_control.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_subsample.py +0 -0
- {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_workflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crispyx
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Memory-efficient streaming analysis of large-scale CRISPR and Perturb-seq screens on disk-backed AnnData files
|
|
5
5
|
Author: Jin-Hong Du
|
|
6
6
|
License: Modified MIT License
|
|
@@ -106,7 +106,7 @@ Requires-Dist: numba>=0.59
|
|
|
106
106
|
Requires-Dist: pandas>=1.5
|
|
107
107
|
Requires-Dist: scipy>=1.10
|
|
108
108
|
Requires-Dist: h5py>=3.0
|
|
109
|
-
Requires-Dist: joblib>=1.
|
|
109
|
+
Requires-Dist: joblib>=1.4
|
|
110
110
|
Requires-Dist: scikit-learn>=1.0
|
|
111
111
|
Requires-Dist: scikit-misc>=0.1.4
|
|
112
112
|
Requires-Dist: scanpy>=1.9.2
|
|
@@ -130,7 +130,6 @@ Requires-Dist: sphinx-rtd-theme>=1.2; extra == "docs"
|
|
|
130
130
|
Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
|
|
131
131
|
Requires-Dist: nbsphinx>=0.9; extra == "docs"
|
|
132
132
|
Requires-Dist: ipykernel>=6.0; extra == "docs"
|
|
133
|
-
Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "docs"
|
|
134
133
|
Dynamic: license-file
|
|
135
134
|
|
|
136
135
|
# crispyx
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "crispyx"
|
|
7
|
-
|
|
7
|
+
dynamic = ["version"]
|
|
8
8
|
description = "Memory-efficient streaming analysis of large-scale CRISPR and Perturb-seq screens on disk-backed AnnData files"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {file = "LICENSE"}
|
|
@@ -40,7 +40,7 @@ dependencies = [
|
|
|
40
40
|
"pandas>=1.5",
|
|
41
41
|
"scipy>=1.10",
|
|
42
42
|
"h5py>=3.0",
|
|
43
|
-
"joblib>=1.
|
|
43
|
+
"joblib>=1.4",
|
|
44
44
|
"scikit-learn>=1.0",
|
|
45
45
|
"scikit-misc>=0.1.4",
|
|
46
46
|
"scanpy>=1.9.2",
|
|
@@ -69,7 +69,6 @@ docs = [
|
|
|
69
69
|
"sphinx-copybutton>=0.5",
|
|
70
70
|
"nbsphinx>=0.9",
|
|
71
71
|
"ipykernel>=6.0",
|
|
72
|
-
"tomli>=2.0; python_version < '3.11'",
|
|
73
72
|
]
|
|
74
73
|
|
|
75
74
|
[project.urls]
|
|
@@ -78,6 +77,9 @@ Documentation = "https://crispyx.readthedocs.io"
|
|
|
78
77
|
Repository = "https://github.com/jinhongdu-lab/crispyx"
|
|
79
78
|
"Bug Tracker" = "https://github.com/jinhongdu-lab/crispyx/issues"
|
|
80
79
|
|
|
80
|
+
[tool.setuptools.dynamic]
|
|
81
|
+
version = {attr = "crispyx._version.__version__"}
|
|
82
|
+
|
|
81
83
|
[tool.setuptools.packages.find]
|
|
82
84
|
where = ["src"]
|
|
83
85
|
|
|
@@ -15,12 +15,7 @@ import os
|
|
|
15
15
|
# Override with NUMBA_THREADING_LAYER=omp if you manage the OMP conflict yourself.
|
|
16
16
|
os.environ.setdefault("NUMBA_THREADING_LAYER", "workqueue")
|
|
17
17
|
|
|
18
|
-
from
|
|
19
|
-
|
|
20
|
-
try:
|
|
21
|
-
__version__ = version("crispyx")
|
|
22
|
-
except PackageNotFoundError:
|
|
23
|
-
__version__ = "0.0.0.dev"
|
|
18
|
+
from ._version import __version__
|
|
24
19
|
|
|
25
20
|
# ---------------------------------------------------------------------------
|
|
26
21
|
# Public API re-exports
|
|
@@ -13,14 +13,16 @@ import json
|
|
|
13
13
|
import logging
|
|
14
14
|
import os
|
|
15
15
|
import shutil
|
|
16
|
-
import warnings
|
|
17
16
|
from pathlib import Path
|
|
18
|
-
from typing import TYPE_CHECKING
|
|
17
|
+
from typing import TYPE_CHECKING, Callable
|
|
19
18
|
|
|
20
19
|
import h5py
|
|
21
20
|
import numpy as np
|
|
22
21
|
import pandas as pd
|
|
23
22
|
|
|
23
|
+
from . import _messages
|
|
24
|
+
from ._memory import drop_file_cache
|
|
25
|
+
|
|
24
26
|
if TYPE_CHECKING:
|
|
25
27
|
from tqdm import tqdm
|
|
26
28
|
|
|
@@ -302,26 +304,104 @@ def _sha256(array: np.ndarray) -> str:
|
|
|
302
304
|
return hashlib.sha256(np.ascontiguousarray(array).tobytes()).hexdigest()
|
|
303
305
|
|
|
304
306
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
+
#: Elements per block when a result matrix is read or rewritten a block of
|
|
308
|
+
#: rows at a time (32 MB of float64), so no step holds a whole result.
|
|
309
|
+
_BLOCK_ELEMENTS = 1 << 22
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _row_blocks(n_rows: int, n_genes: int, chunk_rows: int | None = None):
|
|
313
|
+
"""Slices covering ``n_rows`` rows of ``n_genes`` columns, about
|
|
314
|
+
:data:`_BLOCK_ELEMENTS` elements each. With ``chunk_rows`` (the rows of
|
|
315
|
+
an HDF5 chunk), each slice is whole bands of chunks, so no chunk is read
|
|
316
|
+
twice."""
|
|
317
|
+
step = max(1, _BLOCK_ELEMENTS // max(n_genes, 1))
|
|
318
|
+
if chunk_rows:
|
|
319
|
+
step = max(1, round(step / chunk_rows)) * chunk_rows
|
|
320
|
+
for start in range(0, n_rows, step):
|
|
321
|
+
yield slice(start, min(start + step, n_rows))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _chunk_rows(dataset) -> int | None:
|
|
325
|
+
"""Rows in one HDF5 chunk of ``dataset`` (``None`` when contiguous or
|
|
326
|
+
not an HDF5 dataset)."""
|
|
327
|
+
chunks = getattr(dataset, "chunks", None)
|
|
328
|
+
return chunks[0] if chunks else None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _create_array(group: h5py.Group, name: str, **kwargs) -> h5py.Dataset:
|
|
332
|
+
"""``group.create_dataset`` tagged as an anndata dense array."""
|
|
333
|
+
ds = group.create_dataset(name, **kwargs)
|
|
334
|
+
ds.attrs["encoding-type"] = "array"
|
|
335
|
+
ds.attrs["encoding-version"] = "0.2.0"
|
|
336
|
+
return ds
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _create_unfilled_array(
|
|
340
|
+
group: h5py.Group, name: str, shape: tuple[int, ...], dtype, chunks: tuple[int, ...] | None = None,
|
|
341
|
+
) -> h5py.Dataset:
|
|
342
|
+
"""An anndata dense array whose storage is allocated now but never
|
|
343
|
+
filled: creating it writes nothing (on disk it is a sparse hole until
|
|
344
|
+
written), and the file's layout is final from the start, so filling it
|
|
345
|
+
later only writes data -- a kill mid-write leaves a readable file.
|
|
346
|
+
Unwritten elements read as zero. Contiguous, or in HDF5 ``chunks``
|
|
347
|
+
(every chunk allocated up front, so the layout is still final)."""
|
|
348
|
+
dcpl = h5py.h5p.create(h5py.h5p.DATASET_CREATE)
|
|
349
|
+
dcpl.set_alloc_time(h5py.h5d.ALLOC_TIME_EARLY)
|
|
350
|
+
dcpl.set_fill_time(h5py.h5d.FILL_TIME_NEVER)
|
|
351
|
+
if chunks is not None:
|
|
352
|
+
dcpl.set_chunk(tuple(int(c) for c in chunks))
|
|
353
|
+
return _create_array(group, name, shape=shape, dtype=dtype, dcpl=dcpl)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
#: Bytes in one HDF5 chunk of a result written in column blocks.
|
|
357
|
+
_COLUMN_BLOCK_CHUNK_BYTES = 1 << 20
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _column_block_chunks(n_groups: int, n_genes: int, chunk_size: int) -> tuple[int, int]:
|
|
361
|
+
"""HDF5 chunk shape for a result written one gene chunk (a block of
|
|
362
|
+
``chunk_size`` columns) at a time: ``(rows, chunk_size)``, ~1 MiB.
|
|
363
|
+
|
|
364
|
+
In a row-major contiguous dataset a gene chunk is one small write per
|
|
365
|
+
group row (10,922 x 4.6 KB per layer on a genome-wide screen), each
|
|
366
|
+
straddling partial pages that the filesystem must read before writing.
|
|
367
|
+
On a network filesystem that read is a round trip: on Lustre a
|
|
368
|
+
gene chunk took 219 s to write contiguously even with its pages cached,
|
|
369
|
+
and 918 s when they were evicted between chunks, against 22 s in these
|
|
370
|
+
chunks. On a local disk the two layouts write alike (the chunked one
|
|
371
|
+
slightly faster), so it is used everywhere; reading one row of a large
|
|
372
|
+
result reads its band of chunks (~2 ms locally), and small results are
|
|
373
|
+
read into memory whole anyway.
|
|
374
|
+
"""
|
|
375
|
+
width = max(1, min(int(chunk_size), n_genes))
|
|
376
|
+
rows = max(1, min(n_groups, _COLUMN_BLOCK_CHUNK_BYTES // (width * 8)))
|
|
377
|
+
return rows, width
|
|
307
378
|
|
|
308
|
-
The arrays are memmaps in a hidden ``.{output name}.resume`` directory
|
|
309
|
-
beside the output (a temporary directory would vanish with the process,
|
|
310
|
-
taking the finished work with it). The checkpoint JSON records the call's
|
|
311
|
-
:func:`run_fingerprint` plus caller-defined progress, and :meth:`save`
|
|
312
|
-
flushes the arrays before writing it, so everything a checkpoint lists
|
|
313
|
-
as done is on disk.
|
|
314
379
|
|
|
315
|
-
|
|
316
|
-
|
|
380
|
+
class ResumableRun:
|
|
381
|
+
"""A result file filled in place, and a progress checkpoint, that outlive
|
|
382
|
+
an interruption.
|
|
383
|
+
|
|
384
|
+
The work file is :attr:`work_path`, ``result.h5ad`` in a hidden
|
|
385
|
+
``.{output name}.resume`` directory beside the output (a temporary
|
|
386
|
+
directory would vanish with the process, taking the finished work with
|
|
387
|
+
it). ``scaffold(path)`` creates it with every dataset the run fills,
|
|
388
|
+
pre-sized; the run writes its results into :attr:`file` as they are
|
|
389
|
+
computed, and :meth:`finish` moves the completed file to the output.
|
|
390
|
+
Nothing is held in memory or mapped, so the run's footprint does not
|
|
391
|
+
grow with the result.
|
|
392
|
+
|
|
393
|
+
The checkpoint JSON records the call's :func:`run_fingerprint` plus
|
|
394
|
+
caller-defined progress. :meth:`save` hands the file's pending data to the
|
|
395
|
+
operating system and evicts its pages before writing the checkpoint, so
|
|
396
|
+
everything a checkpoint lists as done survives the process being killed
|
|
397
|
+
and little of it stays charged to the process's memory.
|
|
317
398
|
|
|
318
399
|
A run resumes only when ``resume`` is set, the checkpoint's fingerprint
|
|
319
|
-
matches this call (:func:`fingerprint_difference`), and
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
and says why.
|
|
400
|
+
matches this call (:func:`fingerprint_difference`), and the work file
|
|
401
|
+
opens with every dataset in ``datasets`` at its expected shape and dtype.
|
|
402
|
+
Otherwise the stale checkpoint and directory are discarded and the run
|
|
403
|
+
starts fresh, so a checkpoint can never be paired with another run's
|
|
404
|
+
data; a resume that throws away a checkpoint warns and says why.
|
|
325
405
|
"""
|
|
326
406
|
|
|
327
407
|
def __init__(
|
|
@@ -330,13 +410,15 @@ class ResumableRun:
|
|
|
330
410
|
checkpoint_path: Path,
|
|
331
411
|
*,
|
|
332
412
|
fingerprint: dict,
|
|
333
|
-
arrays: dict[str, tuple[tuple[int, ...], "np.typing.DTypeLike", float | int | bool]],
|
|
334
413
|
resume: bool,
|
|
335
|
-
|
|
414
|
+
scaffold: "Callable[[Path], None]",
|
|
415
|
+
datasets: "dict[str, tuple[tuple[int, ...], np.typing.DTypeLike]]",
|
|
336
416
|
) -> None:
|
|
337
417
|
self.checkpoint_path = checkpoint_path
|
|
338
418
|
self.fingerprint = fingerprint
|
|
339
419
|
self.directory = output_path.with_name(f".{output_path.name}.resume")
|
|
420
|
+
self.work_path = self.directory / "result.h5ad"
|
|
421
|
+
self.file: h5py.File | None = None
|
|
340
422
|
checkpoint = _read_checkpoint(checkpoint_path, required_keys=("fingerprint",)) if resume else None
|
|
341
423
|
mismatch = (
|
|
342
424
|
fingerprint_difference(checkpoint["fingerprint"], fingerprint)
|
|
@@ -344,56 +426,65 @@ class ResumableRun:
|
|
|
344
426
|
else "it cannot be read"
|
|
345
427
|
)
|
|
346
428
|
if checkpoint is not None and mismatch is not None:
|
|
347
|
-
|
|
429
|
+
_messages.emit(
|
|
348
430
|
f"Not resuming from the checkpoint at {checkpoint_path}: {mismatch}. "
|
|
349
431
|
"Discarding it and starting over.",
|
|
350
|
-
stacklevel=3,
|
|
351
432
|
)
|
|
352
433
|
self.resumed = (
|
|
353
|
-
checkpoint is not None
|
|
354
|
-
and mismatch is None
|
|
355
|
-
and all((self.directory / name).exists() for name in requires)
|
|
356
|
-
and all(
|
|
357
|
-
(self.directory / f"{name}.dat").exists()
|
|
358
|
-
and (self.directory / f"{name}.dat").stat().st_size
|
|
359
|
-
== int(np.prod(shape, dtype=np.int64)) * np.dtype(dtype).itemsize
|
|
360
|
-
for name, (shape, dtype, _fill) in arrays.items()
|
|
361
|
-
)
|
|
434
|
+
checkpoint is not None and mismatch is None and _has_datasets(self.work_path, datasets)
|
|
362
435
|
)
|
|
363
436
|
self.progress = {k: v for k, v in checkpoint.items() if k != "fingerprint"} if self.resumed else {}
|
|
364
|
-
self.arrays: dict[str, np.memmap] = {}
|
|
365
437
|
if not self.resumed:
|
|
366
438
|
self.discard()
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if self.resumed:
|
|
371
|
-
self.arrays[name] = np.memmap(path, mode="r+", dtype=dtype, shape=shape)
|
|
372
|
-
else:
|
|
373
|
-
array = np.memmap(path, mode="w+", dtype=dtype, shape=shape)
|
|
374
|
-
if fill != 0:
|
|
375
|
-
array.fill(fill)
|
|
376
|
-
self.arrays[name] = array
|
|
439
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
scaffold(self.work_path)
|
|
441
|
+
self.file = h5py.File(self.work_path, "r+")
|
|
377
442
|
|
|
378
443
|
def save(self, **progress) -> None:
|
|
379
|
-
"""
|
|
380
|
-
|
|
381
|
-
|
|
444
|
+
"""Put the work file's data on disk, then record ``progress`` in the
|
|
445
|
+
checkpoint."""
|
|
446
|
+
self.file.flush()
|
|
447
|
+
drop_file_cache(self.work_path)
|
|
382
448
|
_write_checkpoint_atomic(self.checkpoint_path, {"fingerprint": self.fingerprint, **progress})
|
|
383
449
|
|
|
384
|
-
def
|
|
385
|
-
"""
|
|
450
|
+
def close(self) -> None:
|
|
451
|
+
"""Close the work file, for the steps that finish it by path."""
|
|
452
|
+
if self.file is not None:
|
|
453
|
+
self.file.close()
|
|
454
|
+
self.file = None
|
|
455
|
+
drop_file_cache(self.work_path)
|
|
386
456
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
"""
|
|
390
|
-
self.
|
|
457
|
+
def finish(self, output_path: Path) -> None:
|
|
458
|
+
"""Move the completed work file to ``output_path`` and remove the
|
|
459
|
+
checkpoint and directory."""
|
|
460
|
+
self.close()
|
|
461
|
+
os.replace(self.work_path, output_path)
|
|
462
|
+
self.discard()
|
|
463
|
+
|
|
464
|
+
def discard(self) -> None:
|
|
465
|
+
"""Remove the checkpoint and the work file (after success, or when stale)."""
|
|
466
|
+
if self.file is not None:
|
|
467
|
+
self.file.close()
|
|
468
|
+
self.file = None
|
|
391
469
|
self.checkpoint_path.unlink(missing_ok=True)
|
|
392
|
-
# ignore_errors: on Windows a file still mapped by a live result view
|
|
393
|
-
# cannot be deleted; the next run on this output removes it.
|
|
394
470
|
shutil.rmtree(self.directory, ignore_errors=True)
|
|
395
471
|
|
|
396
472
|
|
|
473
|
+
def _has_datasets(path: Path, datasets: dict) -> bool:
|
|
474
|
+
"""Whether the h5 file at ``path`` opens and holds every dataset in
|
|
475
|
+
``datasets`` with its ``(shape, dtype)``."""
|
|
476
|
+
try:
|
|
477
|
+
with h5py.File(path, "r") as handle:
|
|
478
|
+
return all(
|
|
479
|
+
name in handle
|
|
480
|
+
and handle[name].shape == tuple(shape)
|
|
481
|
+
and handle[name].dtype == np.dtype(dtype)
|
|
482
|
+
for name, (shape, dtype) in datasets.items()
|
|
483
|
+
)
|
|
484
|
+
except (OSError, KeyError): # absent, or a kill mid-write left it unreadable
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
|
|
397
488
|
class _DummyProgress:
|
|
398
489
|
"""Dummy progress bar that does nothing (for when verbose=False)."""
|
|
399
490
|
|
|
@@ -11,7 +11,6 @@ resource allocator. There is no override parameter analogous to
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import shutil
|
|
14
|
-
import warnings
|
|
15
14
|
from dataclasses import dataclass
|
|
16
15
|
from pathlib import Path
|
|
17
16
|
|
|
@@ -194,25 +193,25 @@ def warn_if_disk_space_low(
|
|
|
194
193
|
``verbose``-gated confirmation alongside this unconditional warning
|
|
195
194
|
without re-running the free-space lookup.
|
|
196
195
|
"""
|
|
196
|
+
from . import _messages # _messages imports this module
|
|
197
|
+
|
|
197
198
|
estimate = assess_bytes(required_bytes, output_path)
|
|
198
199
|
label = f"{context}: " if context else ""
|
|
199
200
|
|
|
200
201
|
if estimate.free_bytes is not None and not estimate.sufficient:
|
|
201
|
-
|
|
202
|
+
_messages.emit(
|
|
202
203
|
f"{label}estimated disk usage ({format_bytes(estimate.required_bytes)}) "
|
|
203
204
|
f"leaves less than {min_free_fraction:.0%} free space at {estimate.path} "
|
|
204
205
|
f"({format_bytes(estimate.free_bytes)} currently free). The operation may fail "
|
|
205
206
|
"with 'No space left on device'. Free up space or point output_path/TMPDIR at a "
|
|
206
207
|
"volume with more room before rerunning.",
|
|
207
|
-
stacklevel=3,
|
|
208
208
|
)
|
|
209
209
|
elif estimate.required_gb > large_file_gb:
|
|
210
|
-
|
|
210
|
+
_messages.emit(
|
|
211
211
|
f"{label}this operation will write approximately "
|
|
212
212
|
f"{format_bytes(estimate.required_bytes)} to disk. "
|
|
213
213
|
"Streaming keeps memory bounded, but the on-disk footprint (and the time to write "
|
|
214
214
|
"it) is not -- make sure the output volume has room before running large batches.",
|
|
215
|
-
stacklevel=3,
|
|
216
215
|
)
|
|
217
216
|
|
|
218
217
|
return estimate
|
|
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|
|
30
30
|
from typing import Literal
|
|
31
31
|
|
|
32
32
|
import numpy as np
|
|
33
|
+
from scipy.linalg import cho_factor, cho_solve
|
|
33
34
|
|
|
34
35
|
__all__ = [
|
|
35
36
|
"EPS",
|
|
@@ -40,11 +41,15 @@ __all__ = [
|
|
|
40
41
|
"eta_floor",
|
|
41
42
|
"gram_batched",
|
|
42
43
|
"irls_weights",
|
|
44
|
+
"PIVOT_FLOOR",
|
|
43
45
|
"precondition_columns",
|
|
46
|
+
"sandwich_variances_2x2",
|
|
44
47
|
"schur_complement",
|
|
45
48
|
"schur_solve",
|
|
46
49
|
"schur_variances",
|
|
50
|
+
"solve_spd_2x2",
|
|
47
51
|
"working_response",
|
|
52
|
+
"wls_solve",
|
|
48
53
|
]
|
|
49
54
|
|
|
50
55
|
#: Division guard. Not a modelling floor -- see the module docstring.
|
|
@@ -161,6 +166,74 @@ def gram_batched(
|
|
|
161
166
|
return out
|
|
162
167
|
|
|
163
168
|
|
|
169
|
+
#: Pivot floor of the 2x2 factorisation: a singular system gives large but
|
|
170
|
+
#: finite values rather than inf or nan.
|
|
171
|
+
PIVOT_FLOOR = 1e-12
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def solve_spd_2x2(a, b, c, r0, r1):
|
|
175
|
+
"""Solve ``[[a, b], [b, c]] x = [r0, r1]`` elementwise -- one system per
|
|
176
|
+
gene when the arguments are arrays -- through its ``LDL'``
|
|
177
|
+
factorisation, whose pivots ``a`` and ``c - b**2 / a`` are floored at
|
|
178
|
+
:data:`PIVOT_FLOOR`. Returns ``(x0, x1)``.
|
|
179
|
+
|
|
180
|
+
Only arithmetic and ufuncs, so a Numba kernel can compile it too.
|
|
181
|
+
"""
|
|
182
|
+
d0 = np.maximum(a, PIVOT_FLOOR)
|
|
183
|
+
lower = b / d0
|
|
184
|
+
d1 = np.maximum(c - lower * b, PIVOT_FLOOR)
|
|
185
|
+
x1 = (r1 - lower * r0) / d1
|
|
186
|
+
return (r0 - b * x1) / d0, x1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def sandwich_variances_2x2(m00, m01, m11, ridge):
|
|
190
|
+
"""Sandwich variances ``e_k' H M H e_k`` of a two-column fit's
|
|
191
|
+
coefficients, for Fisher information ``M = [[m00, m01], [m01, m11]]``
|
|
192
|
+
and ``H = (M + ridge I)^-1``, with ``H e_k`` from :func:`solve_spd_2x2`.
|
|
193
|
+
Returns ``(intercept, second column)``.
|
|
194
|
+
"""
|
|
195
|
+
a, c = m00 + ridge, m11 + ridge
|
|
196
|
+
|
|
197
|
+
def quadratic(v0, v1):
|
|
198
|
+
return v0 * v0 * m00 + 2 * v0 * v1 * m01 + v1 * v1 * m11
|
|
199
|
+
|
|
200
|
+
return (quadratic(*solve_spd_2x2(a, m01, c, 1.0, 0.0)),
|
|
201
|
+
quadratic(*solve_spd_2x2(a, m01, c, 0.0, 1.0)))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def wls_solve(
|
|
205
|
+
x_weighted: np.ndarray, z_weighted: np.ndarray | None = None, ridge: float = 0.0
|
|
206
|
+
) -> tuple[np.ndarray | None, np.ndarray]:
|
|
207
|
+
"""Coefficients and covariance ``(X'WX + ridge I)^-1`` of a weighted
|
|
208
|
+
least-squares fit, from its weighted design ``W^1/2 X`` (and response
|
|
209
|
+
``W^1/2 z``; without one the coefficients are ``None``).
|
|
210
|
+
|
|
211
|
+
A Cholesky factor of the normal matrix when it is positive definite;
|
|
212
|
+
otherwise an SVD of the weighted design, stacked on ``sqrt(ridge) I``,
|
|
213
|
+
gives the minimum-norm solution and the pseudo-inverse with singular
|
|
214
|
+
values below ``matrix_rank``'s tolerance dropped -- without forming a
|
|
215
|
+
normal matrix whose condition number is the design's squared.
|
|
216
|
+
"""
|
|
217
|
+
n_features = x_weighted.shape[1]
|
|
218
|
+
gram = x_weighted.T @ x_weighted
|
|
219
|
+
gram[np.diag_indices(n_features)] += ridge
|
|
220
|
+
try:
|
|
221
|
+
factor = cho_factor(gram, check_finite=False)
|
|
222
|
+
except np.linalg.LinAlgError:
|
|
223
|
+
design, response = x_weighted, z_weighted
|
|
224
|
+
if ridge:
|
|
225
|
+
design = np.vstack([x_weighted, np.sqrt(ridge) * np.eye(n_features)])
|
|
226
|
+
if z_weighted is not None:
|
|
227
|
+
response = np.concatenate([z_weighted, np.zeros(n_features)])
|
|
228
|
+
u, s, vt = np.linalg.svd(design, full_matrices=False)
|
|
229
|
+
keep = s > s.max(initial=0.0) * max(design.shape) * np.finfo(np.float64).eps
|
|
230
|
+
scaled = vt[keep].T / s[keep] # V S^-1
|
|
231
|
+
beta = None if response is None else scaled @ (u[:, keep].T @ response)
|
|
232
|
+
return beta, scaled @ scaled.T
|
|
233
|
+
beta = None if z_weighted is None else cho_solve(factor, x_weighted.T @ z_weighted, check_finite=False)
|
|
234
|
+
return beta, cho_solve(factor, np.eye(n_features), check_finite=False)
|
|
235
|
+
|
|
236
|
+
|
|
164
237
|
def irls_weights(mu: np.ndarray, alpha: np.ndarray | float | None) -> np.ndarray:
|
|
165
238
|
"""Log-link IRLS weights ``mu**2 / V(mu)``.
|
|
166
239
|
|
|
@@ -497,15 +570,17 @@ def schur_variances(
|
|
|
497
570
|
(H^-1)_XX = S^-1
|
|
498
571
|
(H^-1)_GG = D^-1 + D^-1 B' S^-1 B D^-1
|
|
499
572
|
|
|
500
|
-
so
|
|
501
|
-
|
|
502
|
-
|
|
573
|
+
so one solve of each ``S`` against ``[I | B]`` gives the covariate
|
|
574
|
+
variances and the quadratic forms ``B_k' S^-1 B_k`` the group variances
|
|
575
|
+
need.
|
|
503
576
|
"""
|
|
504
577
|
inverse_diagonal = 1.0 / diagonal # (a, p)
|
|
505
|
-
|
|
506
|
-
feature_variance = np.diagonal(schur_inverse, axis1=1, axis2=2)
|
|
507
|
-
|
|
578
|
+
n_genes, dx = schur.shape[:2]
|
|
508
579
|
blocks = np.ascontiguousarray(cross.transpose(2, 1, 0)) # (p, dx, a)
|
|
509
|
-
|
|
580
|
+
# One factorisation of each S, solved for [I | B] together.
|
|
581
|
+
rhs = np.concatenate([np.broadcast_to(np.eye(dx), (n_genes, dx, dx)), blocks], axis=2)
|
|
582
|
+
solved = np.linalg.solve(schur, rhs)
|
|
583
|
+
feature_variance = np.diagonal(solved[:, :, :dx], axis1=1, axis2=2)
|
|
584
|
+
quadratic = (blocks * solved[:, :, dx:]).sum(axis=1) # (p, a)
|
|
510
585
|
group_variance = inverse_diagonal.T + quadratic * inverse_diagonal.T**2
|
|
511
586
|
return feature_variance, group_variance
|