11from __future__ import annotations
22
3- import enum
4- from typing import TYPE_CHECKING , NamedTuple
3+ from typing import TYPE_CHECKING
54
65import numpy as np
76
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