Skip to content

feat: move hashsolo to sc.pp - #4303

Open
flying-sheep wants to merge 7 commits into
mainfrom
hashsolo
Open

feat: move hashsolo to sc.pp#4303
flying-sheep wants to merge 7 commits into
mainfrom
hashsolo

Conversation

@flying-sheep

@flying-sheep flying-sheep commented Aug 18, 2026

Copy link
Copy Markdown
Member

Basically just moving the code over and adding an anndata.acc based version of the API on top.

People had issues with our old API since it only supported obs columns:

The new API of course supports everything! Yay accessors!

@flying-sheep flying-sheep changed the title feat: vendor hashsolo feat: move hashsolo to sc.pp Aug 18, 2026
@flying-sheep flying-sheep added this to the 1.13.0 milestone Aug 18, 2026
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.59664% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.04%. Comparing base (6b5405d) to head (5543fb4).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/scanpy/preprocessing/_hashsolo.py 91.37% 10 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4303      +/-   ##
==========================================
+ Coverage   80.01%   80.04%   +0.03%     
==========================================
  Files         132      133       +1     
  Lines       13397    13403       +6     
==========================================
+ Hits        10719    10729      +10     
+ Misses       2678     2674       -4     
Flag Coverage Δ
hatch-test.low-vers 78.22% <85.71%> (-0.02%) ⬇️
hatch-test.pre 79.92% <91.59%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/scanpy/external/pp/_hashsolo.py 100.00% <100.00%> (+11.96%) ⬆️
src/scanpy/preprocessing/__init__.py 100.00% <100.00%> (ø)
src/scanpy/preprocessing/_hashsolo.py 91.37% <91.37%> (ø)

@flying-sheep
flying-sheep marked this pull request as ready for review August 18, 2026 11:15
}


@deprecated(Deprecation("1.13.0", "Use `scanpy.pp.hashsolo` instead."))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering which one should be used because it seems inconsistent with

scrublet = deprecated("Import from sc.pp instead")(_scrublet.scrublet)
scrublet_simulate_doublets = deprecated("Import from sc.pp instead")(
    _scrublet.scrublet_simulate_doublets
)

Comment thread docs/conf.py
"pp.filter_cells": (["np", "sp", "da"], []),
"pp.filter_genes": (["np", "sp", "da"], []),
"pp.harmony_integrate": (["np"], []),
"pp.hashsolo": (["np", "sp"], []),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works silently for dask and will give a "# FutureWarning: The numpy.column_stack function is not implemented by Dask array."

A 2d array of shape `(n_cells, 3)` with the probability of each hypothesis.

"""
log_likelihoods, _, _ = _calculate_log_likelihoods(data, number_of_noise_barcodes)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the other returns of _calculate_log_likelihoods is never used elsewhere

) -> NDArray[np.float64]:
"""Validate counts and run the bayes rule, optionally per cluster."""
if not check_nonnegative_integers(data):
msg = "Cell hashing counts must be non-negative"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also mention they should be an integer no? since people can give normalized log easily now

Comment thread tests/test_hashsolo.py
return Hashed(adata, A.obsm["hto"], names)
case _:
pytest.fail(f"Unknown param {request.param!r}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a test with 0-len, 1-len, 2-len hashes should be tried out to see if it errors gracefully. Because claude tells me this

"
Silent all-NaN → every cell "Negative" when the derived noise-barcode count hits 0
Both return with no exception: adata.obsm["hashsolo"] is entirely NaN, adata.obs["hashsolo"] is {"Negative": n}, and the only signal is a RuntimeWarning: Mean of empty slice from np.mean(data_sort[:, :-n_barcodes]). One barcode gives IndexError: index -2 is out of bounds for axis 1 with size 1.
"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general claude suggested these

test_too_few_noise_barcodes[1-hash] / [2-hashes-default] / [explicit-0]	RuntimeWarning: Mean of empty slice (warning-as-error) instead of the wanted ValueError	#1
test_pre_existing_clusters_missing_label	RuntimeWarning; without warnings-as-error, those rows are [0,0,0] → "Negative"	#2
test_pre_existing_clusters_singleton	RuntimeWarning: divide by zero; otherwise NaN probs	#2
test_pre_existing_clusters for the new ref path
@pytest.mark.parametrize(
    ("n_hashes", "number_of_noise_barcodes"),
    [
        pytest.param(1, None, id="1-hash"),
        pytest.param(2, None, id="2-hashes-default"),
        pytest.param(len(HASHES), 0, id="explicit-0"),
    ],
)
def test_too_few_noise_barcodes(
    adata: AnnData, n_hashes: int, number_of_noise_barcodes: int | None
) -> None:
    """Without a noise barcode, the noise distribution is undefined."""
    with pytest.raises(ValueError, match=r"noise barcodes?"):
        sc.pp.hashsolo(
            adata,
            HASHES[:n_hashes],
            number_of_noise_barcodes=number_of_noise_barcodes,
        )


@pytest.mark.parametrize("kind", ["str", pytest.param("ref", marks=needs.anndata_acc)])
def test_pre_existing_clusters(adata: AnnData, kind: str) -> None:
    """Clustered demultiplexing equals demultiplexing each cluster on its own."""
    adata.obs["cl"] = np.where(np.arange(adata.n_obs) % 2, "a", "b")
    if kind == "ref":
        from anndata.acc import A

        ref = A.obs["cl"]
    else:
        ref = "cl"
    sc.pp.hashsolo(adata, HASHES, pre_existing_clusters=ref)

    np.testing.assert_allclose(adata.obsm["hashsolo"].to_numpy().sum(axis=1), 1)
    for cluster in ("a", "b"):
        sub = adata[adata.obs["cl"] == cluster].copy()
        sc.pp.hashsolo(sub, HASHES)
        np.testing.assert_allclose(
            sub.obsm["hashsolo"].to_numpy(),
            adata.obsm["hashsolo"].loc[sub.obs_names].to_numpy(),
        )


def test_pre_existing_clusters_missing_label(adata: AnnData) -> None:
    """Unlabeled cells must not silently turn into confident negatives."""
    adata.obs["cl"] = pd.Categorical(["a"] * (adata.n_obs - 10) + [None] * 10)
    sc.pp.hashsolo(adata, HASHES, pre_existing_clusters="cl")
    np.testing.assert_allclose(adata.obsm["hashsolo"].to_numpy().sum(axis=1), 1)


def test_pre_existing_clusters_singleton(adata: AnnData) -> None:
    """A one-cell cluster has zero variance; it must not yield NaN probabilities."""
    adata.obs["cl"] = ["a"] * (adata.n_obs - 1) + ["b"]
    sc.pp.hashsolo(adata, HASHES, pre_existing_clusters="cl")
    assert np.isfinite(adata.obsm["hashsolo"].to_numpy()).all()

@selmanozleyen selmanozleyen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some edge cases inherited from main and one new behaviour with the pd.unique and loc usage instead of the old np.unique. Plus one nitpick on dead return variables. But the rest seems good

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

move sc.external.pp.hashsolo into sc.pp

2 participants