From 7c21a4e7af2b1da74745e7f153a70dd3d56bd4ae Mon Sep 17 00:00:00 2001 From: fishidaho Date: Thu, 17 Sep 2026 16:26:35 -0700 Subject: [PATCH] Say plainly that to_scipy_sparse() omits centering, and pin the identity `to_scipy_sparse()` (#47) returns the uncentered `Delta` term and leaves `means` for the caller to subtract. For the two recipes that do not center that is the normalized matrix; for the three that do (`parafac2`, `scanpy`, `pearson`) it is not, and the old docstring mentioned `means` only in passing, after describing the return value in terms that read as complete. Measured on a 40x6 integer matrix, what comes back differs from the real normalized matrix by more than 0.1 for every centering recipe. Lead with that instead, and pin both halves in tests: the identity `to_scipy_sparse().toarray() - means == toarray()` for every recipe, and the fact that dropping `means` really does change the answer for the centering ones -- so the warning cannot quietly stop being true. No API change. An earlier draft added `is_sparse` plus guarded `to_scipy()`/`to_csr()`/`to_csc()`, but `is_sparse` only restated the already-public `recipe.center`, the format helpers only restated scipy's own `.tocsr()`/`.tocsc()`, and a second materializer differing from the first only in whether it raises is more API to understand rather than less. The sparse decomposition itself landed in #47 and #51: `to_scipy_sparse()` is the `sparse_delta()` that work proposed, and `means` is its `baseline` negated. Co-Authored-By: Claude Opus 5 (1M context) --- src/vsparse/_norm_common.py | 5 +++++ tests/test_vcs_norm_recipes.py | 18 ++++++++++++++++++ uv.lock | 6 +++--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/vsparse/_norm_common.py b/src/vsparse/_norm_common.py index 4d5a78f..7c50d09 100644 --- a/src/vsparse/_norm_common.py +++ b/src/vsparse/_norm_common.py @@ -876,6 +876,11 @@ def toarray(self) -> np.ndarray: def to_scipy_sparse(self, dtype: npt.DTypeLike = np.float64) -> Any: """The uncentered, scaled sparse ``Delta`` term, as a real scipy sparse array. + **Not the normalized matrix when the recipe centers.** It omits + :attr:`means`, the value every structural zero carries once centered; + ``parafac2``, ``scanpy`` and ``pearson`` all center. The whole matrix + is ``to_scipy_sparse().toarray() - means``, or :meth:`toarray`. + Same sparsity pattern as the underlying raw array (a ``csr_array`` for a VCSR-backed view, ``csc_array`` for VCSC), with :attr:`means` left to subtract externally -- see :attr:`means`. Useful for handing diff --git a/tests/test_vcs_norm_recipes.py b/tests/test_vcs_norm_recipes.py index e9a4a3e..fa74483 100644 --- a/tests/test_vcs_norm_recipes.py +++ b/tests/test_vcs_norm_recipes.py @@ -356,3 +356,21 @@ def test_anndata_cache_does_not_pin_a_dropped_view(): assert ref() is None # Still reusable -- from obs/varm/uns if not from the retained statistics. assert adata.normalized("scanpy", recalculate=False) is not None + + +@pytest.mark.parametrize("recipe", sorted(RECIPES)) +def test_to_scipy_sparse_minus_means_is_the_normalized_matrix(vcls, dense, recipe): + """``to_scipy_sparse() - means`` must reconstruct the normalized matrix.""" + if dense.sum() == 0: + pytest.skip("all-zero matrix: median row total is 0") + nv = vcls.from_scipy(_scipy_for(vcls, dense)).normalized(recipe) + np.testing.assert_allclose(nv.to_scipy_sparse().toarray() - nv.means, nv.toarray(), atol=1e-10) + + +@pytest.mark.parametrize("recipe", ["parafac2", "scanpy", "pearson"]) +def test_to_scipy_sparse_alone_is_not_the_normalized_matrix_when_centering(vcls, recipe): + """Dropping ``means`` really does change the answer for a centering recipe.""" + rng = np.random.default_rng(0) + dense = rng.integers(1, 50, size=(40, 6)).astype(np.float64) + nv = vcls.from_scipy(_scipy_for(vcls, dense)).normalized(recipe) + assert np.abs(nv.to_scipy_sparse().toarray() - nv.toarray()).max() > 0.1 diff --git a/uv.lock b/uv.lock index 06e08d6..40428c8 100644 --- a/uv.lock +++ b/uv.lock @@ -1496,7 +1496,7 @@ wheels = [ [[package]] name = "vsparse" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "anndata" }, @@ -1516,8 +1516,8 @@ docs = [ [package.dev-dependencies] dev = [ - { name = "hypothesis" }, { name = "codespell" }, + { name = "hypothesis" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -1540,8 +1540,8 @@ provides-extras = ["docs"] [package.metadata.requires-dev] dev = [ - { name = "hypothesis", specifier = ">=6.100" }, { name = "codespell", specifier = ">=2.3" }, + { name = "hypothesis", specifier = ">=6.100" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.6" },