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.
Files changed (45) hide show
  1. {structboost-0.6.0 → structboost-0.6.1}/CHANGELOG.md +28 -0
  2. {structboost-0.6.0 → structboost-0.6.1}/PKG-INFO +1 -1
  3. {structboost-0.6.0 → structboost-0.6.1}/pyproject.toml +1 -1
  4. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_model.py +183 -93
  5. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_utils.py +87 -1
  6. structboost-0.6.1/tests/test_densify.py +236 -0
  7. {structboost-0.6.0 → structboost-0.6.1}/.gitignore +0 -0
  8. {structboost-0.6.0 → structboost-0.6.1}/CITATION.cff +0 -0
  9. {structboost-0.6.0 → structboost-0.6.1}/CODE_OF_CONDUCT.md +0 -0
  10. {structboost-0.6.0 → structboost-0.6.1}/CONTRIBUTING.md +0 -0
  11. {structboost-0.6.0 → structboost-0.6.1}/LICENSE +0 -0
  12. {structboost-0.6.0 → structboost-0.6.1}/README.md +0 -0
  13. {structboost-0.6.0 → structboost-0.6.1}/conftest.py +0 -0
  14. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/__init__.py +0 -0
  15. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_annotation.py +0 -0
  16. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_boosting.py +0 -0
  17. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_decoder.py +0 -0
  18. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_encoder.py +0 -0
  19. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_explorer.py +0 -0
  20. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_io.py +0 -0
  21. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_persistence.py +0 -0
  22. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_plotting.py +0 -0
  23. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_simulation.py +0 -0
  24. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_stability.py +0 -0
  25. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/_types.py +0 -0
  26. {structboost-0.6.0 → structboost-0.6.1}/src/structboost/py.typed +0 -0
  27. {structboost-0.6.0 → structboost-0.6.1}/tests/test_allboost.py +0 -0
  28. {structboost-0.6.0 → structboost-0.6.1}/tests/test_annotation.py +0 -0
  29. {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae.py +0 -0
  30. {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_diagnostics.py +0 -0
  31. {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_init.py +0 -0
  32. {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_layer.py +0 -0
  33. {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_persistence.py +0 -0
  34. {structboost-0.6.0 → structboost-0.6.1}/tests/test_bae_transfer.py +0 -0
  35. {structboost-0.6.0 → structboost-0.6.1}/tests/test_batch_integration.py +0 -0
  36. {structboost-0.6.0 → structboost-0.6.1}/tests/test_covcache_precompute.py +0 -0
  37. {structboost-0.6.0 → structboost-0.6.1}/tests/test_explorer.py +0 -0
  38. {structboost-0.6.0 → structboost-0.6.1}/tests/test_linear_ceiling.py +0 -0
  39. {structboost-0.6.0 → structboost-0.6.1}/tests/test_obs_encoding.py +0 -0
  40. {structboost-0.6.0 → structboost-0.6.1}/tests/test_plotting_dimensions.py +0 -0
  41. {structboost-0.6.0 → structboost-0.6.1}/tests/test_public_api.py +0 -0
  42. {structboost-0.6.0 → structboost-0.6.1}/tests/test_resolve_mandatory.py +0 -0
  43. {structboost-0.6.0 → structboost-0.6.1}/tests/test_rng_isolation.py +0 -0
  44. {structboost-0.6.0 → structboost-0.6.1}/tests/test_simulation.py +0 -0
  45. {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.0
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.0"
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
- if sp.issparse(X):
1482
- X = X.toarray()
1483
- return torch.from_numpy(np.asarray(X, dtype=np.float32)).to(device)
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
- # Prepare data
1722
- matrix = _expression_matrix(adata, self._layer)
1723
- X_np = matrix.toarray() if sp.issparse(matrix) else np.asarray(matrix)
1724
- X_np = X_np.astype(np.float32)
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, sourcemat_aug.shape[1]
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(sourcemat_aug)
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, sourcemat_aug.shape[1], prior_dims, allboost_mandatory
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, sourcemat_aug.shape[1]), dtype=np.float64)
2048
+ betamat = np.zeros((0, design.matrix.shape[1]), dtype=np.float64)
1969
2049
  elif covcache is None:
1970
2050
  betamat, covcache = allboost(
1971
- sourcemat_aug,
2051
+ design.matrix,
1972
2052
  fit_targets,
1973
2053
  covcache=covcache,
1974
- col_norms_sq=boosting_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
- sourcemat_aug,
2066
+ design.matrix,
1987
2067
  fit_targets,
1988
2068
  covcache=covcache,
1989
- col_norms_sq=boosting_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, sourcemat_aug.shape[1], prior_dims)
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 D_nuisance_np is not None else None
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
- self._store_results(adata)
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
- matrix = _expression_matrix(adata, self._layer)
2270
- X_np = matrix.toarray() if sp.issparse(matrix) else np.asarray(matrix)
2271
- X_np = X_np.astype(np.float32)
2272
- X_train = self._to_tensor(X_np, self.config.device)
2273
-
2274
- D_condition = None
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
- # float32, matching `fit`. This path used to promote to float64 on top of
2280
- # the float32 copy above -- three copies of the expression matrix resident
2281
- # at once -- buying a precision difference measured at about one gene in
2282
- # 380, far inside the run-to-run support variation this method documents.
2283
- sourcemat_aug = X_np
2284
- n_nuisance = 0
2285
- if self._regresses_encoder:
2286
- D_nuisance = transform_obs_covariates(adata, self._batch_encoding)
2287
- sourcemat_aug = np.hstack([sourcemat_aug, np.asarray(D_nuisance, dtype=np.float32)])
2288
- n_nuisance = self._batch_encoding.n_columns
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, sourcemat_aug.shape[1]
2382
+ self.config.boosting_precompute_covcache, design.matrix.shape[1]
2302
2383
  ):
2303
- covcache_initial = compute_covariance_cache(sourcemat_aug)
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, sourcemat_aug.shape[1], prior_dims, allboost_mandatory
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, sourcemat_aug.shape[1]), dtype=np.float64)
2467
+ betamat = np.zeros((0, design.matrix.shape[1]), dtype=np.float64)
2387
2468
  else:
2388
2469
  result = allboost(
2389
- sourcemat_aug,
2470
+ design.matrix,
2390
2471
  fit_targets,
2391
2472
  covcache=covcache,
2392
- col_norms_sq=boosting_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, sourcemat_aug.shape[1], prior_dims)
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