Skip to content

Commit ce27b88

Browse files
authored
perf: numba kernels for pp.harmony_integrate (#4424)
1 parent c7301cb commit ce27b88

6 files changed

Lines changed: 1658 additions & 619 deletions

File tree

‎docs/references.bib‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ @article{Angerer2015
3939
pages = {1241--1243},
4040
}
4141

42+
@inproceedings{Bachem2016,
43+
author = {Bachem, Olivier and Lucic, Mario and Hassani, S. Hamed and Krause, Andreas},
44+
title = {Fast and Provably Good Seedings for k-Means},
45+
booktitle = {Advances in Neural Information Processing Systems},
46+
volume = {29},
47+
year = {2016},
48+
}
49+
4250
@article{Baron2016,
4351
author = {Baron, Maayan and Veres, Adrian and Wolock, Samuel L. and Faust, Aubrey L. and Gaujoux, Renaud and Vetere, Amedeo and Ryu, Jennifer Hyoje and Wagner, Bridget K. and Shen-Orr, Shai S. and Klein, Allon M. and Melton, Douglas A. and Yanai, Itai},
4452
title = {A Single-Cell Transcriptomic Map of the Human and Mouse Pancreas Reveals Inter- and Intra-cell Population Structure},

‎docs/release-notes/4424.perf.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Speed up {func}`~scanpy.pp.harmony_integrate` 6–13× with parallel numba kernels, and add the `seeding`, `stop_rule`, `penalty_combine`, `seeding_lloyd_steps`, `seeding_cells_per_cluster`, `recompute_counts` and `low_memory` parameters.
2+
For `flavor="harmony2"`, the default seeding is now `"afkmc2"`, and the default stop rule for both flavors is `"reference_record"`; pass `seeding="kmeans", stop_rule="reference"` for the previous behaviour.
3+
Cells with an all-zero embedding are now left out and returned unchanged {smaller}`T Treis`

‎src/scanpy/preprocessing/_harmony/__init__.py‎

Lines changed: 78 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

3-
import enum
4-
from typing import TYPE_CHECKING, NamedTuple
3+
from typing import TYPE_CHECKING
54

65
import numpy as np
76

@@ -24,20 +23,6 @@
2423
from ...get._types import MultiAcc
2524

2625

27-
class _Stopping(NamedTuple):
28-
max_iter_clustering: int
29-
tol_clustering: float
30-
tol_harmony: float
31-
32-
33-
class Stopping(_Stopping, enum.Enum):
34-
HARMONY2 = (4, 1e-3, 1e-2)
35-
"""Follows harmonypy 2.0.0 / harmony 2.0.5 (R) defaults."""
36-
37-
HARMONY1 = (200, 1e-5, 1e-4)
38-
"""Follows harmony-pytorch defaults."""
39-
40-
4126
@_doc_params(
4227
use=doc_use(
4328
"Which embedding to adjust. By default, the one :func:`~scanpy.pp.pca` wrote.",
@@ -70,13 +55,22 @@ def harmony_integrate( # noqa: PLR0913
7055
batch_prune_threshold: float | None = 1e-5,
7156
correction_method: Literal["fast"] = "fast",
7257
block_proportion: float = 0.05,
58+
stop_rule: Literal[
59+
"reference_record", "reference", "paper", "paper_window3"
60+
] = "reference_record",
61+
seeding: Literal["afkmc2", "kmeans", "kmeans++", "paper_nearest"] | None = None,
62+
seeding_lloyd_steps: int = 0,
63+
seeding_cells_per_cluster: int | None = None,
64+
penalty_combine: Literal["product", "sum"] = "product",
65+
recompute_counts: bool = True,
66+
low_memory: bool = False,
7367
rng: SeedLike | RNGLike | None = None,
7468
) -> np.ndarray | None:
7569
r"""Integrate different experiments using the Harmony algorithm :cite:p:`Korsunsky2019,Patikas2026`.
7670
7771
Integration adjusts a PCA embedding to reduce experiment-specific technical variation,
7872
allowing cells from different experiments to be compared.
79-
This CPU implementation is based on the rapids-singlecell and harmony-pytorch packages.
73+
This CPU implementation runs in parallel numba kernels.
8074
Multiple batch variables follow the per-covariate formulation described in the Harmony papers:
8175
each batch variable is modeled separately instead of combining them all into one joint category.
8276
As Harmony works by adjusting the principal components,
@@ -105,7 +99,7 @@ def harmony_integrate( # noqa: PLR0913
10599
{use}
106100
{out}
107101
dtype
108-
The data type to use for Harmony computation.
102+
The data type to use for Harmony computation, ``float32`` or ``float64``.
109103
If you use 32-bit you may experience numerical instability.
110104
flavor
111105
Which version of the Harmony algorithm to use.
@@ -123,15 +117,18 @@ def harmony_integrate( # noqa: PLR0913
123117
max_iter_clustering
124118
Maximum iterations for the clustering step within each Harmony iteration.
125119
If :data:`None`, uses the value of the reference implementation for the
126-
chosen ``flavor``: ``4`` for ``"harmony2"``, ``200`` for ``"harmony1"``.
120+
chosen ``flavor``: ``4`` for ``"harmony2"``, ``200`` for ``"harmony1"``
121+
(``200`` for both with the ``"paper"`` stop rules).
127122
tol_harmony
128123
Convergence tolerance for the Harmony objective function.
129124
The algorithm stops when the relative change in objective falls below this value.
130-
If :data:`None`, ``1e-2`` for ``"harmony2"`` and ``1e-4`` for ``"harmony1"``.
125+
If :data:`None`, ``1e-2`` for ``"harmony2"`` and ``1e-4`` for ``"harmony1"``
126+
(``1e-4`` for both with the ``"paper"`` stop rules).
131127
tol_clustering
132128
Convergence tolerance for the clustering step within each
133129
Harmony iteration.
134-
If :data:`None`, ``1e-3`` for ``"harmony2"`` and ``1e-5`` for ``"harmony1"``.
130+
If :data:`None`, ``1e-3`` for ``"harmony2"`` and ``1e-5`` for ``"harmony1"``
131+
(``1e-5`` for both with the ``"paper"`` stop rules).
135132
sigma
136133
Width of the soft-clustering kernel.
137134
Controls the entropy of cluster assignments:
@@ -173,14 +170,52 @@ def harmony_integrate( # noqa: PLR0913
173170
Set to ``None`` to disable pruning.
174171
correction_method
175172
Method for the correction step.
176-
``"fast"`` uses a precomputed factorization that avoids the full inversion,
177-
which can be faster for datasets with many batches.
178-
Multiple keys automatically use the exact general-design solve,
179-
because this optimization only applies to a single batch variable.
173+
Only ``"fast"`` is supported: the ridge regression of every cluster is solved exactly,
174+
eliminating the batch variable with the most levels in closed form,
175+
for one or several batch keys.
180176
block_proportion
181177
Proportion of cells updated per clustering sub-iteration.
182178
Smaller values produce more stochastic updates.
183179
Larger values are faster but may converge to different solutions.
180+
stop_rule
181+
When the clustering and the outer Harmony loop stop.
182+
``"reference_record"`` (default) and ``"reference"`` use the iteration limits and tolerances
183+
listed under ``max_iter_clustering``, ``tol_clustering`` and ``tol_harmony``,
184+
and test clustering convergence over a window of 3 iterations.
185+
``"reference_record"`` records every round's objective for the outer test;
186+
``"reference"`` records it only when the clustering converged.
187+
``"paper"`` and ``"paper_window3"`` use the tolerances of :cite:t:`Korsunsky2019`
188+
(200 clustering iterations, ``1e-5`` and ``1e-4``) for both flavors,
189+
testing clustering convergence on the last iteration or a window of 3.
190+
seeding
191+
How the initial cluster centroids are chosen.
192+
If :data:`None`, ``"afkmc2"`` for ``"harmony2"`` and ``"kmeans"`` for ``"harmony1"``.
193+
``"afkmc2"`` approximates k-means++ seeding with Markov chains :cite:p:`Bachem2016`
194+
and needs a single pass over the cells.
195+
``"kmeans"`` runs :class:`~sklearn.cluster.KMeans` (k-means++ and up to 25 Lloyd iterations).
196+
``"kmeans++"`` is exact k-means++ seeding.
197+
``"paper_nearest"`` follows the sampler of :cite:t:`Patikas2026`,
198+
taking the cell with the *highest* ``log(u) / D`` (the paper prints "lowest"),
199+
where ``D`` is the distance to the nearest centroid chosen so far:
200+
this draws cells with probability proportional to that distance.
201+
seeding_lloyd_steps
202+
Hard spherical k-means iterations applied to the seeds before Harmony starts.
203+
seeding_cells_per_cluster
204+
If set, ``seeding="kmeans"`` and the ``seeding_lloyd_steps`` run on a random subsample of
205+
``seeding_cells_per_cluster * n_clusters`` cells.
206+
penalty_combine
207+
How the diversity penalties of several batch keys are combined in the soft assignment.
208+
``"product"`` (default) multiplies the per-key penalty factors, as in :cite:t:`Korsunsky2019`;
209+
``"sum"`` adds them, as the assignment rule is printed in :cite:t:`Patikas2026`,
210+
leaving out levels with ``theta == 0``.
211+
Identical for a single key.
212+
recompute_counts
213+
Rebuild the per-batch cluster counts from the assignments after every clustering iteration.
214+
If ``False``, the counts are only updated incrementally, which accumulates rounding error
215+
in float32.
216+
low_memory
217+
Drop one cell x component buffer during the iterations
218+
at the cost of one extra pass at the end; the result is identical.
184219
rng
185220
Random number generator or seed for deterministic behavior.
186221
@@ -190,26 +225,11 @@ def harmony_integrate( # noqa: PLR0913
190225
It contains the principal components adjusted by Harmony,
191226
such that different experiments are integrated.
192227
"""
193-
from .core import Harmony
228+
from .core import harmonize
194229

195-
# Resolve flavor into internal flags
196-
if flavor not in {"harmony1", "harmony2"}:
197-
msg = f"flavor must be 'harmony1' or 'harmony2', got {flavor!r}."
198-
raise ValueError(msg)
199230
if correction_method != "fast":
200231
msg = f"correction_method must be 'fast', got {correction_method!r}."
201232
raise ValueError(msg)
202-
stabilized_penalty = flavor == "harmony2"
203-
dynamic_lambda = flavor == "harmony2"
204-
205-
# Unset stopping rules follow the flavor's reference implementation
206-
defaults = Stopping[flavor.upper()]
207-
if max_iter_clustering is None:
208-
max_iter_clustering = defaults.max_iter_clustering
209-
if tol_clustering is None:
210-
tol_clustering = defaults.tol_clustering
211-
if tol_harmony is None:
212-
tol_harmony = defaults.tol_harmony
213233

214234
# Warn when flavor-incompatible parameters are explicitly set
215235
if flavor == "harmony2" and ridge_lambda != 1.0:
@@ -244,14 +264,6 @@ def harmony_integrate( # noqa: PLR0913
244264
msg = f"Could not convert input of type {type(input_data).__name__} to NumPy array."
245265
raise TypeError(msg) from e
246266

247-
# Check for NaN values
248-
if np.isnan(x).any():
249-
msg = (
250-
"Input data contains NaN values. Please handle these before "
251-
"running harmony_integrate."
252-
)
253-
raise ValueError(msg)
254-
255267
# Run Harmony
256268
batch_df = _obs_df(
257269
adata,
@@ -262,26 +274,32 @@ def harmony_integrate( # noqa: PLR0913
262274
legacy_name="",
263275
),
264276
)
265-
harmony = Harmony(
277+
harmony_out, _ = harmonize(
278+
x,
266279
batch_df,
267280
list(batch_df.columns),
268-
theta=theta,
269-
sigma=sigma,
281+
flavor=flavor,
270282
n_clusters=n_clusters,
283+
sigma=sigma,
284+
theta=theta,
285+
tau=tau,
286+
ridge_lambda=ridge_lambda,
287+
alpha=alpha,
288+
batch_prune_threshold=batch_prune_threshold,
289+
block_proportion=block_proportion,
271290
max_iter_harmony=max_iter_harmony,
272291
max_iter_clustering=max_iter_clustering,
273292
tol_harmony=tol_harmony,
274293
tol_clustering=tol_clustering,
275-
ridge_lambda=ridge_lambda,
276-
block_proportion=block_proportion,
277-
tau=tau,
294+
stop_rule=stop_rule,
295+
seeding=seeding,
296+
penalty_combine=penalty_combine,
297+
seeding_lloyd_steps=seeding_lloyd_steps,
298+
seeding_cells_per_cluster=seeding_cells_per_cluster,
299+
recompute_counts=recompute_counts,
300+
low_memory=low_memory,
278301
rng=rng,
279-
stabilized_penalty=stabilized_penalty,
280-
dynamic_lambda=dynamic_lambda,
281-
alpha=alpha,
282-
batch_prune_threshold=batch_prune_threshold,
283302
)
284-
harmony_out = harmony.fit(x)
285303

286304
if dest is True:
287305
return harmony_out

0 commit comments

Comments
 (0)