feat: move hashsolo to sc.pp - #4303
Conversation
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more.
|
| } | ||
|
|
||
|
|
||
| @deprecated(Deprecation("1.13.0", "Use `scanpy.pp.hashsolo` instead.")) |
There was a problem hiding this comment.
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
)| "pp.filter_cells": (["np", "sp", "da"], []), | ||
| "pp.filter_genes": (["np", "sp", "da"], []), | ||
| "pp.harmony_integrate": (["np"], []), | ||
| "pp.hashsolo": (["np", "sp"], []), |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
we should also mention they should be an integer no? since people can give normalized log easily now
| return Hashed(adata, A.obsm["hto"], names) | ||
| case _: | ||
| pytest.fail(f"Unknown param {request.param!r}") | ||
|
|
There was a problem hiding this comment.
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.
"
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
Basically just moving the code over and adding an
anndata.accbased version of the API on top.People had issues with our old API since it only supported
obscolumns:The new API of course supports everything! Yay accessors!
sc.external.pp.hashsolointosc.pp#4304