crispyx 0.2.1__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.1/src/crispyx.egg-info → crispyx-0.2.2}/PKG-INFO +1 -2
  2. {crispyx-0.2.1 → crispyx-0.2.2}/pyproject.toml +4 -2
  3. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/__init__.py +1 -6
  4. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_checkpoint.py +75 -3
  5. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_disk.py +4 -5
  6. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_irls.py +82 -7
  7. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_kernels.py +94 -158
  8. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_memory.py +30 -3
  9. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_messages.py +21 -3
  10. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_namespaces.py +9 -15
  11. {crispyx-0.2.1 → 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.1 → crispyx-0.2.2}/src/crispyx/batch.py +0 -6
  14. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/data.py +453 -130
  15. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/de.py +41 -91
  16. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/glm.py +97 -145
  17. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/hvg.py +1 -3
  18. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/pseudobulk.py +81 -81
  19. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/qc.py +10 -0
  20. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/residual.py +4 -0
  21. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/sample.py +4 -0
  22. {crispyx-0.2.1 → crispyx-0.2.2/src/crispyx.egg-info}/PKG-INFO +1 -2
  23. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx.egg-info/SOURCES.txt +2 -0
  24. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx.egg-info/requires.txt +0 -3
  25. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_convert_format.py +147 -1
  26. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_de_resume.py +2 -2
  27. crispyx-0.2.2/tests/test_in_memory_input.py +124 -0
  28. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_irls.py +50 -0
  29. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_messages.py +35 -9
  30. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_nb_glm.py +80 -0
  31. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_pseudobulk_memory.py +37 -0
  32. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_stream_on_fast_axis.py +17 -0
  33. {crispyx-0.2.1 → crispyx-0.2.2}/LICENSE +0 -0
  34. {crispyx-0.2.1 → crispyx-0.2.2}/README.md +0 -0
  35. {crispyx-0.2.1 → crispyx-0.2.2}/setup.cfg +0 -0
  36. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_grouping.py +0 -0
  37. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_h5codec.py +0 -0
  38. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_preflight.py +0 -0
  39. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_provenance.py +0 -0
  40. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/_size_factors.py +0 -0
  41. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/dimred.py +0 -0
  42. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/plotting.py +0 -0
  43. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx/profiling.py +0 -0
  44. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx.egg-info/dependency_links.txt +0 -0
  45. {crispyx-0.2.1 → crispyx-0.2.2}/src/crispyx.egg-info/top_level.txt +0 -0
  46. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_batch_process.py +0 -0
  47. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_cgroup_memory_budget.py +0 -0
  48. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_compress_h5ad.py +0 -0
  49. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_concat_h5ad.py +0 -0
  50. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_data_helpers.py +0 -0
  51. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_de_edge_cases.py +0 -0
  52. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_de_lowexpr_filter.py +0 -0
  53. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_dimred.py +0 -0
  54. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_disk_dispatch.py +0 -0
  55. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_downsample_counts.py +0 -0
  56. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_glm_structured.py +0 -0
  57. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_groupby_alias.py +0 -0
  58. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_hvg.py +0 -0
  59. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_memory_dispatch.py +0 -0
  60. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_normalisation.py +0 -0
  61. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_normalized_effects.py +0 -0
  62. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_normalized_effects_se.py +0 -0
  63. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_obs_writes.py +0 -0
  64. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_pickle.py +0 -0
  65. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_plotting.py +0 -0
  66. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_preflight_dispatch.py +0 -0
  67. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_profiling.py +0 -0
  68. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_pseudobulk_batch.py +0 -0
  69. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_pseudobulk_profiles.py +0 -0
  70. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_qc_parity.py +0 -0
  71. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_rank_genes_groups_outputs.py +0 -0
  72. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_read_backed.py +0 -0
  73. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_residual_effects.py +0 -0
  74. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_result_provenance.py +0 -0
  75. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_scanpy_parity.py +0 -0
  76. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_standardize.py +0 -0
  77. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_streaming_control.py +0 -0
  78. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_subsample.py +0 -0
  79. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_temp_file_cleanup.py +0 -0
  80. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_wilcoxon_dispatch.py +0 -0
  81. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_wilcoxon_labels.py +0 -0
  82. {crispyx-0.2.1 → crispyx-0.2.2}/tests/test_wilcoxon_stratified.py +0 -0
  83. {crispyx-0.2.1 → 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.1
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
@@ -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.1"
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"}
@@ -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,7 +13,6 @@ import json
13
13
  import logging
14
14
  import os
15
15
  import shutil
16
- import warnings
17
16
  from pathlib import Path
18
17
  from typing import TYPE_CHECKING, Callable
19
18
 
@@ -21,6 +20,7 @@ import h5py
21
20
  import numpy as np
22
21
  import pandas as pd
23
22
 
23
+ from . import _messages
24
24
  from ._memory import drop_file_cache
25
25
 
26
26
  if TYPE_CHECKING:
@@ -304,6 +304,79 @@ def _sha256(array: np.ndarray) -> str:
304
304
  return hashlib.sha256(np.ascontiguousarray(array).tobytes()).hexdigest()
305
305
 
306
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
378
+
379
+
307
380
  class ResumableRun:
308
381
  """A result file filled in place, and a progress checkpoint, that outlive
309
382
  an interruption.
@@ -353,10 +426,9 @@ class ResumableRun:
353
426
  else "it cannot be read"
354
427
  )
355
428
  if checkpoint is not None and mismatch is not None:
356
- warnings.warn(
429
+ _messages.emit(
357
430
  f"Not resuming from the checkpoint at {checkpoint_path}: {mismatch}. "
358
431
  "Discarding it and starting over.",
359
- stacklevel=3,
360
432
  )
361
433
  self.resumed = (
362
434
  checkpoint is not None and mismatch is None and _has_datasets(self.work_path, datasets)
@@ -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