structboost 0.6.0__tar.gz → 0.6.1__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.
- {structboost-0.6.0 → structboost-0.6.1}/CHANGELOG.md +28 -0
- {structboost-0.6.0 → structboost-0.6.1}/PKG-INFO +1 -1
- {structboost-0.6.0 → structboost-0.6.1}/pyproject.toml +1 -1
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_model.py +183 -93
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_utils.py +87 -1
- structboost-0.6.1/tests/test_densify.py +236 -0
- {structboost-0.6.0 → structboost-0.6.1}/.gitignore +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/CITATION.cff +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/CODE_OF_CONDUCT.md +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/CONTRIBUTING.md +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/LICENSE +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/README.md +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/conftest.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/__init__.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_annotation.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_boosting.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_decoder.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_encoder.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_explorer.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_io.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_persistence.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_plotting.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_simulation.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_stability.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_types.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/src/structboost/py.typed +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_allboost.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_annotation.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_diagnostics.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_init.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_layer.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_persistence.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_transfer.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_batch_integration.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_covcache_precompute.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_explorer.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_linear_ceiling.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_obs_encoding.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_plotting_dimensions.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_public_api.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_resolve_mandatory.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_rng_isolation.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_simulation.py +0 -0
- {structboost-0.6.0 → structboost-0.6.1}/tests/test_stability.py +0 -0
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
Releases follow [semantic versioning](https://semver.org). While the project is
|
|
4
4
|
pre-1.0, a minor bump may break API.
|
|
5
5
|
|
|
6
|
+
### [0.6.1] - 2026-09-24
|
|
7
|
+
|
|
8
|
+
**Not breaking.** CSR and dense input give bit-identical fits; CSC input
|
|
9
|
+
changes in the last bits (below).
|
|
10
|
+
|
|
11
|
+
**Fits need less memory and run faster.** Measured at 300,000 cells by 2,000
|
|
12
|
+
genes: peak memory 10.4 GB to 8.3 GB, wall clock 44.8 s to 25.9 s. Three copies
|
|
13
|
+
of the expression matrix are gone:
|
|
14
|
+
|
|
15
|
+
- The sparse matrix is densified in row blocks straight into float32, instead of
|
|
16
|
+
through a full-size intermediate in its source dtype.
|
|
17
|
+
- `fit` hands the training matrix to the step that writes `adata.obsm["X_bae"]`
|
|
18
|
+
instead of densifying `adata.X` a second time.
|
|
19
|
+
- With batch integration on (`batch_integration_mode` `"encoder"` or `"both"`),
|
|
20
|
+
the boosting design is allocated once with room for the covariate columns,
|
|
21
|
+
instead of copying the whole panel with `np.hstack` to append them. Batch
|
|
22
|
+
integration now costs about the same peak memory as a fit without it, and
|
|
23
|
+
400,000 cells fit where they ran out of memory before.
|
|
24
|
+
|
|
25
|
+
A float32 `adata.X` is now trained on in place rather than copied. A fit never
|
|
26
|
+
writes to it; a read-only array is still copied.
|
|
27
|
+
|
|
28
|
+
**CSC input now gives the same fit as CSR.** `csc.toarray()` returns a
|
|
29
|
+
Fortran-ordered array, which changed the BLAS reduction order and with it the
|
|
30
|
+
fitted weights (2.3e-9 on a 12-iteration fit). The densified matrix is now
|
|
31
|
+
row-major whatever the input, so a CSC-backed fit will not reproduce its earlier
|
|
32
|
+
result, and matches the CSR result for the same data instead.
|
|
33
|
+
|
|
6
34
|
### [0.6.0] - 2026-09-09
|
|
7
35
|
|
|
8
36
|
**Not breaking.** Nothing existing changes behaviour; this adds a way to read a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: structboost
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Boosting Autoencoders (BAE) and componentwise L2 boosting utilities for scverse-style workflows.
|
|
5
5
|
Project-URL: Homepage, https://github.com/NiklasBrunn/structboost
|
|
6
6
|
Project-URL: Documentation, https://niklasbrunn.github.io/structboost
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "structboost"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.1"
|
|
8
8
|
description = "Boosting Autoencoders (BAE) and componentwise L2 boosting utilities for scverse-style workflows."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import copy
|
|
6
6
|
import functools
|
|
7
7
|
import warnings
|
|
8
|
-
from dataclasses import fields, replace
|
|
8
|
+
from dataclasses import dataclass, fields, replace
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import TYPE_CHECKING, Literal
|
|
11
11
|
|
|
@@ -275,6 +275,38 @@ def _build_allboost_mandatory(
|
|
|
275
275
|
return np.concatenate([resolved_mandatory, obs_indices])
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
@dataclass(frozen=True)
|
|
279
|
+
class _BoostingDesign:
|
|
280
|
+
"""The matrix ``allboost`` is fitted against, and the parts of it that are fixed.
|
|
281
|
+
|
|
282
|
+
Built the same way by :meth:`BAE.fit` and by
|
|
283
|
+
:meth:`BAE._iteration_support_frequency`, which documents itself as mirroring
|
|
284
|
+
the fit loop and whose equivalence ``tests/test_stability.py`` pins. Sharing
|
|
285
|
+
the construction is what keeps that true rather than merely intended.
|
|
286
|
+
|
|
287
|
+
Attributes
|
|
288
|
+
----------
|
|
289
|
+
panel
|
|
290
|
+
``(n_cells, n_genes)`` expression, float32 — what the encoder reads. When
|
|
291
|
+
there are nuisance columns this is a column *view* of ``matrix``, so the
|
|
292
|
+
two are never separate copies of the same numbers.
|
|
293
|
+
matrix
|
|
294
|
+
``(n_cells, n_genes + n_nuisance)`` — what boosting actually sees.
|
|
295
|
+
col_norms_sq
|
|
296
|
+
Squared column norms of ``matrix``. Fixed for a whole fit, so ``allboost``
|
|
297
|
+
is handed them instead of repeating an O(n*p) pass, and a full-size
|
|
298
|
+
temporary, on every training iteration.
|
|
299
|
+
mandatory_ridge
|
|
300
|
+
Per-column ridge for the unpenalized block, non-zero only on the nuisance
|
|
301
|
+
columns. Mandatory *gene* coefficients are never penalized.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
panel: np.ndarray
|
|
305
|
+
matrix: np.ndarray
|
|
306
|
+
col_norms_sq: np.ndarray
|
|
307
|
+
mandatory_ridge: np.ndarray
|
|
308
|
+
|
|
309
|
+
|
|
278
310
|
class _FitLayer:
|
|
279
311
|
"""Sentinel: "whichever layer this model was fitted on"."""
|
|
280
312
|
|
|
@@ -1477,10 +1509,68 @@ class BAE(nn.Module):
|
|
|
1477
1509
|
|
|
1478
1510
|
@staticmethod
|
|
1479
1511
|
def _to_tensor(X: np.ndarray | sp.spmatrix, device: torch.device) -> torch.Tensor:
|
|
1480
|
-
"""Convert array or sparse matrix to tensor.
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1512
|
+
"""Convert array or sparse matrix to tensor.
|
|
1513
|
+
|
|
1514
|
+
A float32 ndarray is shared, not copied, so the tensor may alias the
|
|
1515
|
+
caller's ``adata.X``: nothing downstream may write to it. A read-only
|
|
1516
|
+
array is copied instead, since torch cannot hold one without warning.
|
|
1517
|
+
"""
|
|
1518
|
+
from ._utils import densify
|
|
1519
|
+
|
|
1520
|
+
X = densify(X)
|
|
1521
|
+
return torch.from_numpy(X if X.flags.writeable else X.copy()).to(device)
|
|
1522
|
+
|
|
1523
|
+
def _build_boosting_design(
|
|
1524
|
+
self,
|
|
1525
|
+
matrix: np.ndarray | sp.spmatrix,
|
|
1526
|
+
*,
|
|
1527
|
+
covariates: np.ndarray | None = None,
|
|
1528
|
+
n_nuisance: int = 0,
|
|
1529
|
+
) -> _BoostingDesign:
|
|
1530
|
+
"""Read the expression panel into the design matrix boosting is fitted on.
|
|
1531
|
+
|
|
1532
|
+
One allocation holds both blocks and the panel is densified straight into
|
|
1533
|
+
the gene block. ``np.hstack`` instead builds the augmented matrix *from*
|
|
1534
|
+
an already-dense panel, so two full copies are resident at once; and
|
|
1535
|
+
:func:`~structboost._utils.densify` fills the block in row blocks, so no
|
|
1536
|
+
full-size intermediate in the source dtype appears either.
|
|
1537
|
+
|
|
1538
|
+
The returned ``panel`` is then a column view of the design: C-ordered
|
|
1539
|
+
within each row and differing only in row stride, which BLAS takes as a
|
|
1540
|
+
leading dimension. Torch runs the encoder on it without materializing a
|
|
1541
|
+
contiguous copy, and returns bit-identical results — see
|
|
1542
|
+
``tests/test_densify.py``, which pins that, since a torch that copied
|
|
1543
|
+
instead would silently restore the second panel this avoids.
|
|
1544
|
+
|
|
1545
|
+
Parameters
|
|
1546
|
+
----------
|
|
1547
|
+
matrix
|
|
1548
|
+
Expression, dense or sparse, as :func:`_expression_matrix` returns it.
|
|
1549
|
+
covariates
|
|
1550
|
+
Encoded obs covariates, float32. Required when ``n_nuisance`` is set.
|
|
1551
|
+
n_nuisance
|
|
1552
|
+
Width of the nuisance block; ``0`` leaves the design gene-only, and
|
|
1553
|
+
``panel`` is then the design itself rather than a view into it.
|
|
1554
|
+
"""
|
|
1555
|
+
from ._utils import densify
|
|
1556
|
+
|
|
1557
|
+
if n_nuisance:
|
|
1558
|
+
full = np.empty((matrix.shape[0], self.n_genes + n_nuisance), dtype=np.float32)
|
|
1559
|
+
panel = densify(matrix, out=full[:, : self.n_genes])
|
|
1560
|
+
full[:, self.n_genes :] = covariates
|
|
1561
|
+
else:
|
|
1562
|
+
panel = full = densify(matrix)
|
|
1563
|
+
|
|
1564
|
+
ridge = np.zeros(full.shape[1], dtype=np.float64)
|
|
1565
|
+
if n_nuisance:
|
|
1566
|
+
ridge[self.n_genes :] = self.config.nuisance_ridge
|
|
1567
|
+
|
|
1568
|
+
return _BoostingDesign(
|
|
1569
|
+
panel=panel,
|
|
1570
|
+
matrix=full,
|
|
1571
|
+
col_norms_sq=column_norms_sq(full),
|
|
1572
|
+
mandatory_ridge=ridge,
|
|
1573
|
+
)
|
|
1484
1574
|
|
|
1485
1575
|
@_isolates_torch_rng(config_fallback=True)
|
|
1486
1576
|
def fit(
|
|
@@ -1660,7 +1750,6 @@ class BAE(nn.Module):
|
|
|
1660
1750
|
raise ValueError("batch_key must name at least one obs column")
|
|
1661
1751
|
batch_mode = "both" if batch_integration_mode is _MODE_UNSET else batch_integration_mode
|
|
1662
1752
|
conditions_decoder, regresses_encoder = _BATCH_MODES[batch_mode]
|
|
1663
|
-
nuisance_ridge = self.config.nuisance_ridge
|
|
1664
1753
|
|
|
1665
1754
|
resolved_mandatory = resolve_mandatory_genes(mandatory_genes, adata)
|
|
1666
1755
|
self._mandatory_genes = mandatory_genes
|
|
@@ -1718,10 +1807,35 @@ class BAE(nn.Module):
|
|
|
1718
1807
|
if torch.cuda.is_available():
|
|
1719
1808
|
torch.cuda.manual_seed_all(seed)
|
|
1720
1809
|
|
|
1721
|
-
#
|
|
1722
|
-
matrix
|
|
1723
|
-
|
|
1724
|
-
|
|
1810
|
+
# --- Batch covariates: one encoding, two mechanisms it can drive ---
|
|
1811
|
+
# Resolved before the expression matrix is read, because how many nuisance
|
|
1812
|
+
# columns the boosting design carries decides how wide the array it is
|
|
1813
|
+
# read into has to be.
|
|
1814
|
+
self._batch_encoding = None
|
|
1815
|
+
self._batch_integration_mode = batch_mode
|
|
1816
|
+
D_condition: torch.Tensor | None = None
|
|
1817
|
+
covariates_np: np.ndarray | None = None
|
|
1818
|
+
n_nuisance = 0
|
|
1819
|
+
if batch_columns is not None:
|
|
1820
|
+
from ._utils import encode_obs_covariates
|
|
1821
|
+
|
|
1822
|
+
self._batch_encoding = encode_obs_covariates(adata, batch_columns)
|
|
1823
|
+
# Both mechanisms read the same numbers, so they share one float32
|
|
1824
|
+
# copy of them rather than casting the encoding twice.
|
|
1825
|
+
covariates_np = self._batch_encoding.encoded.astype(np.float32)
|
|
1826
|
+
if conditions_decoder:
|
|
1827
|
+
D_condition = torch.from_numpy(covariates_np).to(self.config.device)
|
|
1828
|
+
if regresses_encoder:
|
|
1829
|
+
n_nuisance = self._batch_encoding.n_columns
|
|
1830
|
+
|
|
1831
|
+
# Prepare data: one allocation for the whole boosting design, with the
|
|
1832
|
+
# panel read straight into its gene block. See `_build_boosting_design`.
|
|
1833
|
+
design = self._build_boosting_design(
|
|
1834
|
+
_expression_matrix(adata, self._layer),
|
|
1835
|
+
covariates=covariates_np,
|
|
1836
|
+
n_nuisance=n_nuisance,
|
|
1837
|
+
)
|
|
1838
|
+
X_np = design.panel
|
|
1725
1839
|
|
|
1726
1840
|
# Warn if data doesn't appear standardized. The result also decides whether
|
|
1727
1841
|
# a PCA warm start needs to center the data (it must not re-transform data
|
|
@@ -1738,9 +1852,6 @@ class BAE(nn.Module):
|
|
|
1738
1852
|
# Use full dataset for training (no validation split)
|
|
1739
1853
|
X_train = self._to_tensor(X_np, self.config.device)
|
|
1740
1854
|
|
|
1741
|
-
# Covariance cache for boosting (uses training data only)
|
|
1742
|
-
X_train_np = X_train.cpu().numpy()
|
|
1743
|
-
|
|
1744
1855
|
# --- Latent state initialization (warm start) ---
|
|
1745
1856
|
if init_pretrain_epochs < 0:
|
|
1746
1857
|
raise ValueError(f"init_pretrain_epochs must be >= 0, got {init_pretrain_epochs}")
|
|
@@ -1775,23 +1886,6 @@ class BAE(nn.Module):
|
|
|
1775
1886
|
else None
|
|
1776
1887
|
)
|
|
1777
1888
|
|
|
1778
|
-
# --- Batch covariates: one encoding, two mechanisms it can drive ---
|
|
1779
|
-
self._batch_encoding = None
|
|
1780
|
-
self._batch_integration_mode = batch_mode
|
|
1781
|
-
D_condition: torch.Tensor | None = None
|
|
1782
|
-
D_nuisance_np: np.ndarray | None = None
|
|
1783
|
-
n_nuisance = 0
|
|
1784
|
-
if batch_columns is not None:
|
|
1785
|
-
from ._utils import encode_obs_covariates
|
|
1786
|
-
|
|
1787
|
-
self._batch_encoding = encode_obs_covariates(adata, batch_columns)
|
|
1788
|
-
if conditions_decoder:
|
|
1789
|
-
condition_np = self._batch_encoding.encoded.astype(np.float32)
|
|
1790
|
-
D_condition = torch.from_numpy(condition_np).to(self.config.device)
|
|
1791
|
-
if regresses_encoder:
|
|
1792
|
-
D_nuisance_np = self._batch_encoding.encoded.astype(np.float32)
|
|
1793
|
-
n_nuisance = self._batch_encoding.n_columns
|
|
1794
|
-
|
|
1795
1889
|
# Always rebuild so repeated fits cannot retain a stale conditioning shape.
|
|
1796
1890
|
decoder_input = (
|
|
1797
1891
|
2 * self.config.latent_dim if self.config.split_softmax else self.config.latent_dim
|
|
@@ -1815,17 +1909,8 @@ class BAE(nn.Module):
|
|
|
1815
1909
|
torch.cuda.manual_seed_all(seed)
|
|
1816
1910
|
self.decoder.reset_parameters()
|
|
1817
1911
|
|
|
1818
|
-
# Build augmented sourcemat for allboost
|
|
1819
|
-
if D_nuisance_np is not None:
|
|
1820
|
-
sourcemat_aug = np.hstack([X_train_np, D_nuisance_np])
|
|
1821
|
-
else:
|
|
1822
|
-
sourcemat_aug = X_train_np
|
|
1823
|
-
|
|
1824
1912
|
# Build combined mandatory indices (genes + obs covariates)
|
|
1825
1913
|
allboost_mandatory = _build_allboost_mandatory(resolved_mandatory, n_nuisance, self.n_genes)
|
|
1826
|
-
mandatory_ridge = np.zeros(sourcemat_aug.shape[1], dtype=np.float64)
|
|
1827
|
-
if n_nuisance:
|
|
1828
|
-
mandatory_ridge[self.n_genes :] = nuisance_ridge
|
|
1829
1914
|
|
|
1830
1915
|
# A frozen transfer with no added dimensions never calls allboost at all
|
|
1831
1916
|
# (there is nothing left to select), so building a covariance matrix for
|
|
@@ -1839,21 +1924,16 @@ class BAE(nn.Module):
|
|
|
1839
1924
|
from ._utils import resolve_precompute_covcache
|
|
1840
1925
|
|
|
1841
1926
|
precompute_covcache = boosts_anything and resolve_precompute_covcache(
|
|
1842
|
-
self.config.boosting_precompute_covcache,
|
|
1927
|
+
self.config.boosting_precompute_covcache, design.matrix.shape[1]
|
|
1843
1928
|
)
|
|
1844
1929
|
self._precomputed_covcache = precompute_covcache
|
|
1845
1930
|
if precompute_covcache:
|
|
1846
1931
|
from ._utils import compute_covariance_cache
|
|
1847
1932
|
|
|
1848
|
-
covcache = compute_covariance_cache(
|
|
1933
|
+
covcache = compute_covariance_cache(design.matrix)
|
|
1849
1934
|
else:
|
|
1850
1935
|
covcache = None # Lazy computation during allboost
|
|
1851
1936
|
|
|
1852
|
-
# `sourcemat_aug` is fixed for the whole fit, so its column norms are too.
|
|
1853
|
-
# allboost recomputes them on every call otherwise -- an O(n*p) pass and a
|
|
1854
|
-
# full-size temporary, once per training iteration.
|
|
1855
|
-
boosting_col_norms_sq = column_norms_sq(sourcemat_aug)
|
|
1856
|
-
|
|
1857
1937
|
# Optimizer for decoder only
|
|
1858
1938
|
decoder_optimizer = torch.optim.AdamW(
|
|
1859
1939
|
self.decoder.parameters(),
|
|
@@ -1960,46 +2040,46 @@ class BAE(nn.Module):
|
|
|
1960
2040
|
# model the prior columns are either withheld ("frozen") or boosted
|
|
1961
2041
|
# from the fixed original matrix as an offset ("anchored").
|
|
1962
2042
|
fit_targets, beta_init, fit_mandatory = self._transfer_boosting_inputs(
|
|
1963
|
-
targets,
|
|
2043
|
+
targets, design.matrix.shape[1], prior_dims, allboost_mandatory
|
|
1964
2044
|
)
|
|
1965
2045
|
if fit_targets.shape[1] == 0:
|
|
1966
2046
|
# Frozen transfer with no additional dimensions: nothing competes
|
|
1967
2047
|
# for selection, and only the decoder adapts to the new data.
|
|
1968
|
-
betamat = np.zeros((0,
|
|
2048
|
+
betamat = np.zeros((0, design.matrix.shape[1]), dtype=np.float64)
|
|
1969
2049
|
elif covcache is None:
|
|
1970
2050
|
betamat, covcache = allboost(
|
|
1971
|
-
|
|
2051
|
+
design.matrix,
|
|
1972
2052
|
fit_targets,
|
|
1973
2053
|
covcache=covcache,
|
|
1974
|
-
col_norms_sq=
|
|
2054
|
+
col_norms_sq=design.col_norms_sq,
|
|
1975
2055
|
stepno=self.config.boosting_stepno,
|
|
1976
2056
|
nu=self.config.boosting_nu,
|
|
1977
2057
|
csf=self.config.boosting_csf,
|
|
1978
2058
|
independent=self.config.boosting_independent,
|
|
1979
2059
|
mandatory_features=fit_mandatory,
|
|
1980
|
-
mandatory_ridge=mandatory_ridge,
|
|
2060
|
+
mandatory_ridge=design.mandatory_ridge,
|
|
1981
2061
|
beta_init=beta_init,
|
|
1982
2062
|
return_covcache=True,
|
|
1983
2063
|
)
|
|
1984
2064
|
else:
|
|
1985
2065
|
betamat = allboost(
|
|
1986
|
-
|
|
2066
|
+
design.matrix,
|
|
1987
2067
|
fit_targets,
|
|
1988
2068
|
covcache=covcache,
|
|
1989
|
-
col_norms_sq=
|
|
2069
|
+
col_norms_sq=design.col_norms_sq,
|
|
1990
2070
|
stepno=self.config.boosting_stepno,
|
|
1991
2071
|
nu=self.config.boosting_nu,
|
|
1992
2072
|
csf=self.config.boosting_csf,
|
|
1993
2073
|
independent=self.config.boosting_independent,
|
|
1994
2074
|
mandatory_features=fit_mandatory,
|
|
1995
|
-
mandatory_ridge=mandatory_ridge,
|
|
2075
|
+
mandatory_ridge=design.mandatory_ridge,
|
|
1996
2076
|
beta_init=beta_init,
|
|
1997
2077
|
)
|
|
1998
|
-
betamat = self._expand_transfer_betamat(betamat,
|
|
2078
|
+
betamat = self._expand_transfer_betamat(betamat, design.matrix.shape[1], prior_dims)
|
|
1999
2079
|
|
|
2000
2080
|
# Extract gene weights only; obs weights are nuisance (discarded)
|
|
2001
2081
|
W_genes = betamat[:, : self.n_genes]
|
|
2002
|
-
batch_weights = betamat[:, self.n_genes :].copy() if
|
|
2082
|
+
batch_weights = betamat[:, self.n_genes :].copy() if n_nuisance else None
|
|
2003
2083
|
W = torch.from_numpy(W_genes.astype(np.float32)).to(self.config.device)
|
|
2004
2084
|
self.encoder.set_weights(W)
|
|
2005
2085
|
|
|
@@ -2089,8 +2169,10 @@ class BAE(nn.Module):
|
|
|
2089
2169
|
|
|
2090
2170
|
self._is_fitted = True
|
|
2091
2171
|
|
|
2092
|
-
# Store results in AnnData
|
|
2093
|
-
|
|
2172
|
+
# Store results in AnnData. `X_train` is handed over rather than re-read:
|
|
2173
|
+
# it is the same matrix, and densifying it twice doubles the peak at the
|
|
2174
|
+
# point in a fit where the most else is already resident.
|
|
2175
|
+
self._store_results(adata, X_train)
|
|
2094
2176
|
|
|
2095
2177
|
# Optional stability selection of the encoder's gene sets. Off by default:
|
|
2096
2178
|
# it adds a fraction of one fit's cost (see BAE.stability_selection).
|
|
@@ -2266,41 +2348,40 @@ class BAE(nn.Module):
|
|
|
2266
2348
|
if seed is not None:
|
|
2267
2349
|
torch.manual_seed(seed)
|
|
2268
2350
|
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
if self._conditions_decoder:
|
|
2276
|
-
D_condition = self._to_tensor(
|
|
2277
|
-
transform_obs_covariates(adata, self._batch_encoding), self.config.device
|
|
2351
|
+
# One encoding drives both mechanisms. Under "both" this used to re-run
|
|
2352
|
+
# `transform_obs_covariates` over the same obs columns for each of them.
|
|
2353
|
+
covariates_np = None
|
|
2354
|
+
if self._conditions_decoder or self._regresses_encoder:
|
|
2355
|
+
covariates_np = np.asarray(
|
|
2356
|
+
transform_obs_covariates(adata, self._batch_encoding), dtype=np.float32
|
|
2278
2357
|
)
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
#
|
|
2282
|
-
#
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2358
|
+
n_nuisance = self._batch_encoding.n_columns if self._regresses_encoder else 0
|
|
2359
|
+
|
|
2360
|
+
# The same design `fit` builds, float32 throughout: this path used to
|
|
2361
|
+
# promote to float64 on top of the float32 copy -- three copies of the
|
|
2362
|
+
# expression matrix resident at once -- buying a precision difference
|
|
2363
|
+
# measured at about one gene in 380, far inside the run-to-run support
|
|
2364
|
+
# variation this method documents.
|
|
2365
|
+
design = self._build_boosting_design(
|
|
2366
|
+
_expression_matrix(adata, self._layer),
|
|
2367
|
+
covariates=covariates_np,
|
|
2368
|
+
n_nuisance=n_nuisance,
|
|
2369
|
+
)
|
|
2370
|
+
X_train = self._to_tensor(design.panel, self.config.device)
|
|
2371
|
+
|
|
2372
|
+
D_condition = (
|
|
2373
|
+
self._to_tensor(covariates_np, self.config.device) if self._conditions_decoder else None
|
|
2374
|
+
)
|
|
2289
2375
|
|
|
2290
2376
|
resolved_mandatory = resolve_mandatory_genes(self._mandatory_genes, adata)
|
|
2291
2377
|
allboost_mandatory = _build_allboost_mandatory(resolved_mandatory, n_nuisance, self.n_genes)
|
|
2292
|
-
mandatory_ridge = np.zeros(sourcemat_aug.shape[1], dtype=np.float64)
|
|
2293
|
-
if n_nuisance:
|
|
2294
|
-
mandatory_ridge[self.n_genes :] = self.config.nuisance_ridge
|
|
2295
|
-
# Fixed across every iteration recorded here, exactly as in `fit`.
|
|
2296
|
-
boosting_col_norms_sq = column_norms_sq(sourcemat_aug)
|
|
2297
2378
|
# Honour the same covariance-cache setting `fit` resolved. Without this,
|
|
2298
2379
|
# the method documented as mirroring the fit loop would run a different
|
|
2299
2380
|
# cache strategy from the fit it is analysing.
|
|
2300
2381
|
if resolve_precompute_covcache(
|
|
2301
|
-
self.config.boosting_precompute_covcache,
|
|
2382
|
+
self.config.boosting_precompute_covcache, design.matrix.shape[1]
|
|
2302
2383
|
):
|
|
2303
|
-
covcache_initial = compute_covariance_cache(
|
|
2384
|
+
covcache_initial = compute_covariance_cache(design.matrix)
|
|
2304
2385
|
else:
|
|
2305
2386
|
covcache_initial = None
|
|
2306
2387
|
|
|
@@ -2380,22 +2461,22 @@ class BAE(nn.Module):
|
|
|
2380
2461
|
)
|
|
2381
2462
|
self.encoder.reset_weights()
|
|
2382
2463
|
fit_targets, beta_init, fit_mandatory = self._transfer_boosting_inputs(
|
|
2383
|
-
targets,
|
|
2464
|
+
targets, design.matrix.shape[1], prior_dims, allboost_mandatory
|
|
2384
2465
|
)
|
|
2385
2466
|
if fit_targets.shape[1] == 0:
|
|
2386
|
-
betamat = np.zeros((0,
|
|
2467
|
+
betamat = np.zeros((0, design.matrix.shape[1]), dtype=np.float64)
|
|
2387
2468
|
else:
|
|
2388
2469
|
result = allboost(
|
|
2389
|
-
|
|
2470
|
+
design.matrix,
|
|
2390
2471
|
fit_targets,
|
|
2391
2472
|
covcache=covcache,
|
|
2392
|
-
col_norms_sq=
|
|
2473
|
+
col_norms_sq=design.col_norms_sq,
|
|
2393
2474
|
stepno=self.config.boosting_stepno,
|
|
2394
2475
|
nu=self.config.boosting_nu,
|
|
2395
2476
|
csf=self.config.boosting_csf,
|
|
2396
2477
|
independent=self.config.boosting_independent,
|
|
2397
2478
|
mandatory_features=fit_mandatory,
|
|
2398
|
-
mandatory_ridge=mandatory_ridge,
|
|
2479
|
+
mandatory_ridge=design.mandatory_ridge,
|
|
2399
2480
|
beta_init=beta_init,
|
|
2400
2481
|
return_covcache=covcache is None,
|
|
2401
2482
|
)
|
|
@@ -2403,7 +2484,7 @@ class BAE(nn.Module):
|
|
|
2403
2484
|
betamat, covcache = result
|
|
2404
2485
|
else:
|
|
2405
2486
|
betamat = result
|
|
2406
|
-
betamat = self._expand_transfer_betamat(betamat,
|
|
2487
|
+
betamat = self._expand_transfer_betamat(betamat, design.matrix.shape[1], prior_dims)
|
|
2407
2488
|
|
|
2408
2489
|
W_genes = betamat[:, : self.n_genes]
|
|
2409
2490
|
self.encoder.set_weights(
|
|
@@ -3138,7 +3219,7 @@ class BAE(nn.Module):
|
|
|
3138
3219
|
explained = 1.0 - ss_error / ss_total if ss_total > 0 else float("nan")
|
|
3139
3220
|
return residual, explained
|
|
3140
3221
|
|
|
3141
|
-
def _store_results(self, adata: AnnData) -> None:
|
|
3222
|
+
def _store_results(self, adata: AnnData, X: torch.Tensor) -> None:
|
|
3142
3223
|
"""Store results in AnnData (scverse convention).
|
|
3143
3224
|
|
|
3144
3225
|
Two of the recorded metrics answer different questions and are easy to
|
|
@@ -3162,9 +3243,18 @@ class BAE(nn.Module):
|
|
|
3162
3243
|
:func:`~structboost.linear_ceiling` for the achievable maximum. It is
|
|
3163
3244
|
written by every fit; only ``reconstruction_loss_by_obs`` and
|
|
3164
3245
|
``latent_obs_r2_per_dim`` require a covariate.
|
|
3165
|
-
"""
|
|
3166
|
-
X = self._to_tensor(_expression_matrix(adata, self._layer), self.config.device)
|
|
3167
3246
|
|
|
3247
|
+
Parameters
|
|
3248
|
+
----------
|
|
3249
|
+
adata
|
|
3250
|
+
Receives the embedding, the encoder weights and the metadata below.
|
|
3251
|
+
X
|
|
3252
|
+
The training matrix ``fit`` is already holding. This used to be read
|
|
3253
|
+
and densified a second time here, putting two ``(n_cells, n_genes)``
|
|
3254
|
+
float32 arrays on the heap at the one moment a fit has the most else
|
|
3255
|
+
resident -- 2.4 GB of them at 300,000 cells by 2,000 genes. There is
|
|
3256
|
+
only ever one caller, and it has the identical tensor in hand.
|
|
3257
|
+
"""
|
|
3168
3258
|
# Recorded so a fitted model is itself a usable prior for a later transfer:
|
|
3169
3259
|
# `from_reference` needs gene identifiers, which the weight matrix lacks.
|
|
3170
3260
|
self._var_names = np.asarray(adata.var_names, dtype=object)
|
|
@@ -6,12 +6,98 @@ from dataclasses import dataclass
|
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
|
-
from numpy.typing import NDArray
|
|
9
|
+
from numpy.typing import DTypeLike, NDArray
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
12
|
from anndata import AnnData
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
#: Bytes of dense output materialized per row block by :func:`densify`. Large
|
|
16
|
+
#: enough that the per-block cost of slicing a CSR matrix is negligible against
|
|
17
|
+
#: the copy itself, small enough that the temporary never rivals the result.
|
|
18
|
+
_DENSIFY_BLOCK_BYTES = 32 * 1024**2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def densify(
|
|
22
|
+
matrix: object,
|
|
23
|
+
*,
|
|
24
|
+
dtype: DTypeLike = np.float32,
|
|
25
|
+
out: NDArray[np.floating] | None = None,
|
|
26
|
+
block_bytes: int = _DENSIFY_BLOCK_BYTES,
|
|
27
|
+
) -> NDArray[np.floating]:
|
|
28
|
+
"""Dense array of ``dtype`` from a possibly-sparse matrix, block by block.
|
|
29
|
+
|
|
30
|
+
``matrix.toarray().astype(dtype)`` holds the dense matrix twice at once: the
|
|
31
|
+
source-dtype array returned by ``toarray`` and the converted copy. On a
|
|
32
|
+
float64 CSR panel that peaks at ``12 * n * p`` bytes to produce a ``4 * n * p``
|
|
33
|
+
byte result — 18 GB for 500,000 cells x 3,000 genes, which is where a fit
|
|
34
|
+
stops being possible rather than merely slow. Converting in row blocks
|
|
35
|
+
straight into the output removes the intermediate entirely: the peak becomes
|
|
36
|
+
the result plus one block.
|
|
37
|
+
|
|
38
|
+
**The bytes produced are identical to the unchunked expression.** ``toarray``
|
|
39
|
+
scatters stored values into a zero-filled buffer and the ``dtype`` conversion
|
|
40
|
+
is elementwise, so neither depends on how the rows are grouped. No arithmetic
|
|
41
|
+
is reassociated, and nothing here rounds differently.
|
|
42
|
+
|
|
43
|
+
Copies only when it must, like :func:`numpy.asarray`: an ndarray that already
|
|
44
|
+
has ``dtype`` is returned as-is. Callers use the result read-only and let it
|
|
45
|
+
go out of scope, so the array is never aliased into a fitted model.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
matrix
|
|
50
|
+
Dense array-like, or any SciPy sparse matrix. Non-CSR sparse formats are
|
|
51
|
+
converted to CSR first, because row slicing is what the block loop needs
|
|
52
|
+
and doing it per block on a column-major format costs ``O(nnz)`` each
|
|
53
|
+
time. That conversion allocates ``nnz``-sized arrays, not ``n * p`` ones.
|
|
54
|
+
dtype
|
|
55
|
+
Output dtype. Defaults to ``float32``, the precision a fit runs in.
|
|
56
|
+
Ignored when ``out`` is given, which carries its own.
|
|
57
|
+
out
|
|
58
|
+
Write into this array instead of allocating one, and return it. It may be
|
|
59
|
+
a *view*, which is the point: a caller building a wider design matrix can
|
|
60
|
+
hand over a column block and have the panel read straight into place,
|
|
61
|
+
rather than densifying separately and copying the result in. Unlike the
|
|
62
|
+
allocating form this always writes, so a dense input is copied.
|
|
63
|
+
block_bytes
|
|
64
|
+
Target size of one row block in the output. The block is always at least
|
|
65
|
+
one row, so a panel wider than this still works.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
Dense ``(n_samples, n_features)`` array of ``dtype``, or ``out``.
|
|
70
|
+
"""
|
|
71
|
+
import scipy.sparse as sp
|
|
72
|
+
|
|
73
|
+
dtype = np.dtype(dtype)
|
|
74
|
+
if out is not None:
|
|
75
|
+
if tuple(out.shape) != tuple(matrix.shape):
|
|
76
|
+
raise ValueError(f"out must have shape {tuple(matrix.shape)}, got {tuple(out.shape)}")
|
|
77
|
+
dtype = out.dtype
|
|
78
|
+
|
|
79
|
+
if not sp.issparse(matrix):
|
|
80
|
+
if out is None:
|
|
81
|
+
return np.asarray(matrix, dtype=dtype)
|
|
82
|
+
# Straight elementwise conversion into place: no dense intermediate in
|
|
83
|
+
# the source dtype, exactly as in the block loop below.
|
|
84
|
+
out[...] = matrix
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
n_rows, n_cols = matrix.shape
|
|
88
|
+
if out is None:
|
|
89
|
+
out = np.empty((n_rows, n_cols), dtype=dtype)
|
|
90
|
+
if out.size == 0:
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
source = matrix if matrix.format == "csr" else matrix.tocsr()
|
|
94
|
+
rows_per_block = max(1, block_bytes // (n_cols * dtype.itemsize))
|
|
95
|
+
for start in range(0, n_rows, rows_per_block):
|
|
96
|
+
stop = min(start + rows_per_block, n_rows)
|
|
97
|
+
out[start:stop] = source[start:stop].toarray()
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
15
101
|
def compute_covariance_cache(
|
|
16
102
|
sourcemat: NDArray[np.floating],
|
|
17
103
|
*,
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Contracts for :func:`structboost._utils.densify`.
|
|
2
|
+
|
|
3
|
+
The helper exists to cut peak memory, so the thing worth pinning is that it buys
|
|
4
|
+
that without moving a single bit: every expression matrix a fit reads goes
|
|
5
|
+
through it, and a changed byte there changes the fitted model. These tests
|
|
6
|
+
therefore compare raw bytes rather than using ``allclose``.
|
|
7
|
+
|
|
8
|
+
They also pin the property the helper introduced on top of that: a fit no longer
|
|
9
|
+
depends on which sparse format ``adata.X`` happens to be stored in.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from structboost._utils import densify
|
|
18
|
+
|
|
19
|
+
sp = pytest.importorskip("scipy.sparse")
|
|
20
|
+
|
|
21
|
+
#: Every sparse layout an AnnData is realistically carrying, plus both dtypes.
|
|
22
|
+
FORMATS = ("csr", "csc", "coo")
|
|
23
|
+
DTYPES = (np.float32, np.float64)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reference(matrix, dtype=np.float32):
|
|
27
|
+
"""What the call sites did before ``densify`` existed."""
|
|
28
|
+
dense = matrix.toarray() if sp.issparse(matrix) else np.asarray(matrix)
|
|
29
|
+
return dense.astype(dtype)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def matrix():
|
|
34
|
+
rng = np.random.default_rng(11)
|
|
35
|
+
x = rng.standard_normal((257, 83))
|
|
36
|
+
# Zeros are what makes a sparse format worth using, and they are also the
|
|
37
|
+
# entries `toarray` writes rather than scatters.
|
|
38
|
+
x[rng.random(x.shape) < 0.6] = 0.0
|
|
39
|
+
return x
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _as_sparse(x, fmt, dtype):
|
|
43
|
+
return getattr(sp, f"{fmt}_matrix")(x.astype(dtype))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestByteIdentity:
|
|
47
|
+
@pytest.mark.parametrize("fmt", FORMATS)
|
|
48
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
49
|
+
def test_matches_unchunked_conversion_exactly(self, matrix, fmt, dtype):
|
|
50
|
+
sparse = _as_sparse(matrix, fmt, dtype)
|
|
51
|
+
assert densify(sparse).tobytes() == _reference(sparse).tobytes()
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
54
|
+
def test_dense_input_matches(self, matrix, dtype):
|
|
55
|
+
dense = matrix.astype(dtype)
|
|
56
|
+
assert densify(dense).tobytes() == _reference(dense).tobytes()
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize("block_bytes", [1, 64, 1024, 1 << 20])
|
|
59
|
+
def test_block_size_never_changes_the_bytes(self, matrix, block_bytes):
|
|
60
|
+
"""The block loop is a pure partition of the rows: no reassociation, so
|
|
61
|
+
every block size -- including one row at a time -- gives the same array."""
|
|
62
|
+
sparse = _as_sparse(matrix, "csr", np.float64)
|
|
63
|
+
whole = densify(sparse)
|
|
64
|
+
assert densify(sparse, block_bytes=block_bytes).tobytes() == whole.tobytes()
|
|
65
|
+
|
|
66
|
+
def test_block_size_below_one_row_still_works(self, matrix):
|
|
67
|
+
"""A panel wider than the block budget must not produce a zero-row step."""
|
|
68
|
+
sparse = _as_sparse(matrix, "csr", np.float64)
|
|
69
|
+
assert densify(sparse, block_bytes=0).tobytes() == densify(sparse).tobytes()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TestWritingIntoAView:
|
|
73
|
+
"""``out=`` is what lets the boosting design be one allocation.
|
|
74
|
+
|
|
75
|
+
``fit`` builds ``(n_cells, n_genes + n_nuisance)`` up front and reads the
|
|
76
|
+
panel into its gene block, instead of densifying separately and copying the
|
|
77
|
+
result in with ``hstack``.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("fmt", (*FORMATS, "dense"))
|
|
81
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
82
|
+
def test_column_block_of_a_wider_array(self, matrix, fmt, dtype):
|
|
83
|
+
payload = matrix.astype(dtype) if fmt == "dense" else _as_sparse(matrix, fmt, dtype)
|
|
84
|
+
n_rows, n_cols = matrix.shape
|
|
85
|
+
design = np.empty((n_rows, n_cols + 3), dtype=np.float32)
|
|
86
|
+
block = densify(payload, out=design[:, :n_cols])
|
|
87
|
+
|
|
88
|
+
assert block.base is design
|
|
89
|
+
assert not block.flags["C_CONTIGUOUS"] # a strided view, by construction
|
|
90
|
+
# Same values as the allocating form, which is what keeps a fit identical.
|
|
91
|
+
assert block.tobytes() == _reference(payload).tobytes()
|
|
92
|
+
|
|
93
|
+
def test_nuisance_columns_are_untouched(self, matrix):
|
|
94
|
+
n_rows, n_cols = matrix.shape
|
|
95
|
+
design = np.zeros((n_rows, n_cols + 2), dtype=np.float32)
|
|
96
|
+
design[:, n_cols:] = 7.5
|
|
97
|
+
densify(_as_sparse(matrix, "csr", np.float64), out=design[:, :n_cols])
|
|
98
|
+
assert (design[:, n_cols:] == 7.5).all()
|
|
99
|
+
|
|
100
|
+
def test_out_dtype_wins_over_dtype_argument(self, matrix):
|
|
101
|
+
out = np.empty(matrix.shape, dtype=np.float64)
|
|
102
|
+
assert densify(_as_sparse(matrix, "csr", np.float64), dtype=np.float32, out=out) is out
|
|
103
|
+
assert out.dtype == np.float64
|
|
104
|
+
|
|
105
|
+
def test_shape_mismatch_is_rejected(self, matrix):
|
|
106
|
+
"""Silently filling the wrong block would corrupt the design matrix."""
|
|
107
|
+
sparse = _as_sparse(matrix, "csr", np.float64)
|
|
108
|
+
with pytest.raises(ValueError, match="out must have shape"):
|
|
109
|
+
densify(sparse, out=np.empty((matrix.shape[0], matrix.shape[1] + 1), np.float32))
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize("block_bytes", [1, 4096, 1 << 20])
|
|
112
|
+
def test_block_size_invariant_when_writing_into_a_view(self, matrix, block_bytes):
|
|
113
|
+
"""The same partition property as the allocating form, but the block
|
|
114
|
+
loop now writes across a row stride, so it is worth pinning separately."""
|
|
115
|
+
sparse = _as_sparse(matrix, "csr", np.float64)
|
|
116
|
+
n_rows, n_cols = matrix.shape
|
|
117
|
+
design = np.empty((n_rows, n_cols + 3), dtype=np.float32)
|
|
118
|
+
block = densify(sparse, out=design[:, :n_cols], block_bytes=block_bytes)
|
|
119
|
+
assert block.tobytes() == densify(sparse).tobytes()
|
|
120
|
+
|
|
121
|
+
def test_torch_reads_the_block_without_copying_it(self, matrix):
|
|
122
|
+
"""The assumption the single-allocation design rests on.
|
|
123
|
+
|
|
124
|
+
The gene block has a row stride of ``n_genes + n_nuisance``, which BLAS
|
|
125
|
+
takes as a leading dimension, so the encoder's matmul runs on it directly.
|
|
126
|
+
If torch ever materialized a contiguous copy instead, the second full
|
|
127
|
+
panel this construction exists to avoid would silently come back.
|
|
128
|
+
"""
|
|
129
|
+
torch = pytest.importorskip("torch")
|
|
130
|
+
n_rows, n_cols = matrix.shape
|
|
131
|
+
design = np.empty((n_rows, n_cols + 4), dtype=np.float32)
|
|
132
|
+
block = densify(_as_sparse(matrix, "csr", np.float64), out=design[:, :n_cols])
|
|
133
|
+
|
|
134
|
+
strided = torch.from_numpy(block)
|
|
135
|
+
contiguous = torch.from_numpy(np.ascontiguousarray(block))
|
|
136
|
+
assert not strided.is_contiguous()
|
|
137
|
+
|
|
138
|
+
torch.manual_seed(0)
|
|
139
|
+
layer = torch.nn.Linear(n_cols, 3, bias=False)
|
|
140
|
+
with torch.no_grad():
|
|
141
|
+
from_view, from_copy = layer(strided), layer(contiguous)
|
|
142
|
+
assert from_view.numpy().tobytes() == from_copy.numpy().tobytes()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestOutputContract:
|
|
146
|
+
@pytest.mark.parametrize("fmt", FORMATS)
|
|
147
|
+
def test_sparse_input_is_c_contiguous(self, matrix, fmt):
|
|
148
|
+
"""Row-major whatever the source format.
|
|
149
|
+
|
|
150
|
+
``csc.toarray()`` returns a Fortran-ordered array, which made the fitted
|
|
151
|
+
model depend on the storage format (different BLAS tiling) and handed
|
|
152
|
+
torch a non-contiguous training tensor. One layout for every input.
|
|
153
|
+
"""
|
|
154
|
+
result = densify(_as_sparse(matrix, fmt, np.float64))
|
|
155
|
+
assert result.flags["C_CONTIGUOUS"]
|
|
156
|
+
|
|
157
|
+
@pytest.mark.parametrize("fmt", FORMATS)
|
|
158
|
+
@pytest.mark.parametrize("dtype", DTYPES)
|
|
159
|
+
def test_dtype_and_shape(self, matrix, fmt, dtype):
|
|
160
|
+
result = densify(_as_sparse(matrix, fmt, dtype))
|
|
161
|
+
assert result.dtype == np.float32
|
|
162
|
+
assert result.shape == matrix.shape
|
|
163
|
+
|
|
164
|
+
def test_honours_requested_dtype(self, matrix):
|
|
165
|
+
assert densify(_as_sparse(matrix, "csr", np.float64), dtype=np.float64).dtype == np.float64
|
|
166
|
+
|
|
167
|
+
def test_matching_dense_input_is_not_copied(self, matrix):
|
|
168
|
+
"""`np.asarray` semantics: no needless duplicate of an already-dense panel."""
|
|
169
|
+
dense = matrix.astype(np.float32)
|
|
170
|
+
assert densify(dense) is dense
|
|
171
|
+
|
|
172
|
+
@pytest.mark.parametrize("shape", [(0, 5), (5, 0), (0, 0)])
|
|
173
|
+
def test_empty(self, shape):
|
|
174
|
+
result = densify(sp.csr_matrix(np.zeros(shape)))
|
|
175
|
+
assert result.shape == shape
|
|
176
|
+
assert result.dtype == np.float32
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _fit(adata, **kwargs):
|
|
180
|
+
"""A short, seeded fit; returns the model."""
|
|
181
|
+
from structboost import BAE, BAEConfig
|
|
182
|
+
|
|
183
|
+
model = BAE(adata.n_vars, BAEConfig(latent_dim=3, max_iterations=6, seed=5, batch_size=64))
|
|
184
|
+
model.fit(adata, verbose=False, **kwargs)
|
|
185
|
+
return model
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.fixture
|
|
189
|
+
def standardized():
|
|
190
|
+
rng = np.random.default_rng(3)
|
|
191
|
+
x = rng.standard_normal((120, 40))
|
|
192
|
+
return (x - x.mean(axis=0)) / x.std(axis=0)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TestStorageFormatIndependence:
|
|
196
|
+
"""The same data must give the same model however AnnData stored it."""
|
|
197
|
+
|
|
198
|
+
def test_csr_csc_dense_agree_bitwise(self, standardized):
|
|
199
|
+
"""Regression test: ``csc.toarray()`` is Fortran-ordered, so before
|
|
200
|
+
``densify`` the storage format changed the BLAS reduction order and with
|
|
201
|
+
it the fitted weights, by ~2e-9 on a short fit."""
|
|
202
|
+
pytest.importorskip("torch")
|
|
203
|
+
anndata = pytest.importorskip("anndata")
|
|
204
|
+
|
|
205
|
+
# AnnData stores CSR and CSC only, which is also the whole population of
|
|
206
|
+
# formats this property can be violated by.
|
|
207
|
+
def weights(payload):
|
|
208
|
+
return _fit(anndata.AnnData(X=payload)).get_encoder_weights().tobytes()
|
|
209
|
+
|
|
210
|
+
reference = weights(sp.csr_matrix(standardized))
|
|
211
|
+
for payload in (sp.csc_matrix(standardized), standardized):
|
|
212
|
+
assert weights(payload) == reference, type(payload).__name__
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TestInputIsLeftAlone:
|
|
216
|
+
"""A float32 ``adata.X`` is trained on in place rather than copied, so the
|
|
217
|
+
training tensor aliases the user's data. Nothing in a fit may write to it."""
|
|
218
|
+
|
|
219
|
+
@pytest.mark.parametrize("fit_kwargs", [{}, {"init_pca": True}, {"batch_key": "batch"}])
|
|
220
|
+
def test_fit_does_not_modify_adata_x(self, standardized, fit_kwargs):
|
|
221
|
+
pytest.importorskip("torch")
|
|
222
|
+
anndata = pytest.importorskip("anndata")
|
|
223
|
+
x = standardized.astype(np.float32)
|
|
224
|
+
adata = anndata.AnnData(X=x.copy())
|
|
225
|
+
adata.obs["batch"] = np.where(np.arange(x.shape[0]) % 2, "a", "b")
|
|
226
|
+
_fit(adata, **fit_kwargs)
|
|
227
|
+
assert adata.X.tobytes() == x.tobytes()
|
|
228
|
+
|
|
229
|
+
def test_read_only_input_fits_without_a_torch_warning(self, standardized):
|
|
230
|
+
pytest.importorskip("torch")
|
|
231
|
+
anndata = pytest.importorskip("anndata")
|
|
232
|
+
x = standardized.astype(np.float32)
|
|
233
|
+
x.setflags(write=False)
|
|
234
|
+
with warnings.catch_warnings():
|
|
235
|
+
warnings.filterwarnings("error", message=".*not writable.*")
|
|
236
|
+
_fit(anndata.AnnData(X=x))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|