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.
Files changed (83) hide show
  1. {crispyx-0.2.0/src/crispyx.egg-info → crispyx-0.2.2}/PKG-INFO +2 -3
  2. {crispyx-0.2.0 → crispyx-0.2.2}/pyproject.toml +5 -3
  3. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/__init__.py +1 -6
  4. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_checkpoint.py +144 -53
  5. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_disk.py +4 -5
  6. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_irls.py +82 -7
  7. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_kernels.py +94 -158
  8. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_memory.py +165 -138
  9. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_messages.py +21 -3
  10. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_namespaces.py +9 -15
  11. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_statistics.py +6 -20
  12. crispyx-0.2.2/src/crispyx/_version.py +5 -0
  13. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/batch.py +0 -6
  14. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/data.py +621 -245
  15. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/de.py +1094 -2020
  16. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/glm.py +195 -207
  17. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/hvg.py +1 -3
  18. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/pseudobulk.py +81 -81
  19. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/qc.py +105 -148
  20. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/residual.py +4 -0
  21. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/sample.py +4 -0
  22. {crispyx-0.2.0 → crispyx-0.2.2/src/crispyx.egg-info}/PKG-INFO +2 -3
  23. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx.egg-info/SOURCES.txt +3 -0
  24. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx.egg-info/requires.txt +1 -4
  25. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_cgroup_memory_budget.py +68 -0
  26. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_concat_h5ad.py +2 -0
  27. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_convert_format.py +147 -1
  28. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_data_helpers.py +15 -0
  29. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_de_resume.py +104 -66
  30. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_disk_dispatch.py +4 -0
  31. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_downsample_counts.py +2 -0
  32. crispyx-0.2.2/tests/test_in_memory_input.py +124 -0
  33. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_irls.py +50 -0
  34. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_memory_dispatch.py +25 -49
  35. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_messages.py +35 -9
  36. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_nb_glm.py +98 -0
  37. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_pickle.py +16 -1
  38. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_pseudobulk_memory.py +37 -0
  39. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_qc_parity.py +4 -2
  40. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_result_provenance.py +18 -25
  41. crispyx-0.2.2/tests/test_standardize.py +102 -0
  42. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_stream_on_fast_axis.py +17 -0
  43. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_temp_file_cleanup.py +1 -1
  44. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_wilcoxon_dispatch.py +54 -94
  45. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_wilcoxon_labels.py +3 -2
  46. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_wilcoxon_stratified.py +3 -1
  47. {crispyx-0.2.0 → crispyx-0.2.2}/LICENSE +0 -0
  48. {crispyx-0.2.0 → crispyx-0.2.2}/README.md +0 -0
  49. {crispyx-0.2.0 → crispyx-0.2.2}/setup.cfg +0 -0
  50. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_grouping.py +0 -0
  51. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_h5codec.py +0 -0
  52. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_preflight.py +0 -0
  53. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_provenance.py +0 -0
  54. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/_size_factors.py +0 -0
  55. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/dimred.py +0 -0
  56. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/plotting.py +0 -0
  57. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx/profiling.py +0 -0
  58. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx.egg-info/dependency_links.txt +0 -0
  59. {crispyx-0.2.0 → crispyx-0.2.2}/src/crispyx.egg-info/top_level.txt +0 -0
  60. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_batch_process.py +0 -0
  61. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_compress_h5ad.py +0 -0
  62. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_de_edge_cases.py +0 -0
  63. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_de_lowexpr_filter.py +0 -0
  64. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_dimred.py +0 -0
  65. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_glm_structured.py +0 -0
  66. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_groupby_alias.py +0 -0
  67. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_hvg.py +0 -0
  68. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_normalisation.py +0 -0
  69. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_normalized_effects.py +0 -0
  70. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_normalized_effects_se.py +0 -0
  71. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_obs_writes.py +0 -0
  72. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_plotting.py +0 -0
  73. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_preflight_dispatch.py +0 -0
  74. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_profiling.py +0 -0
  75. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_pseudobulk_batch.py +0 -0
  76. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_pseudobulk_profiles.py +0 -0
  77. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_rank_genes_groups_outputs.py +0 -0
  78. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_read_backed.py +0 -0
  79. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_residual_effects.py +0 -0
  80. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_scanpy_parity.py +0 -0
  81. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_streaming_control.py +0 -0
  82. {crispyx-0.2.0 → crispyx-0.2.2}/tests/test_subsample.py +0 -0
  83. {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.0
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.0
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
- version = "0.2.0"
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.0",
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 importlib.metadata import PackageNotFoundError, version
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
- class ResumableRun:
306
- """Result arrays and a progress checkpoint that outlive an interruption.
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
- A caller that writes its partial output itself (rather than into
316
- arrays) keeps it in :attr:`directory` too and names it in ``requires``.
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 every array
320
- exists with the expected shape and dtype (plus every file named in
321
- ``requires``). Otherwise the stale checkpoint and directory are
322
- discarded and the run starts fresh, so a checkpoint can never be paired
323
- with another run's data; a resume that throws away a checkpoint warns
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
- requires: tuple[str, ...] = (),
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
- warnings.warn(
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
- self.directory.mkdir(parents=True, exist_ok=True)
368
- for name, (shape, dtype, fill) in arrays.items():
369
- path = self.directory / f"{name}.dat"
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
- """Flush the arrays, then record ``progress`` in the checkpoint."""
380
- for array in self.arrays.values():
381
- array.flush()
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 discard(self) -> None:
385
- """Remove the checkpoint and the arrays (after success, or when stale).
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
- Drops this object's references to the arrays; once the caller drops
388
- its own, the mapped pages are released.
389
- """
390
- self.arrays = {}
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
- warnings.warn(
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
- warnings.warn(
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 the covariate variances come free from the factorisation already
501
- computed by :func:`schur_solve` and the group variances need only the
502
- quadratic form ``B_k' S^-1 B_k`` per group.
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
- schur_inverse = np.linalg.inv(schur) # (p, dx, dx)
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
- quadratic = (blocks * np.matmul(schur_inverse, blocks)).sum(axis=1) # (p, a)
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