From 9d71da6f823213e7af1cbbe3d41417b77bd77549 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Sun, 22 Feb 2026 21:57:29 +0100 Subject: [PATCH 1/9] bench --- scripts/bench_ligrec.py | 222 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 scripts/bench_ligrec.py diff --git a/scripts/bench_ligrec.py b/scripts/bench_ligrec.py new file mode 100644 index 00000000..a20c0d85 --- /dev/null +++ b/scripts/bench_ligrec.py @@ -0,0 +1,222 @@ +""" +Benchmark script for ligrec() -- compare main vs refactored branch. + +Usage: + python scripts/bench_ligrec.py # default config + python scripts/bench_ligrec.py --n-perms 500 # fewer perms (faster) + python scripts/bench_ligrec.py --n-cells 50000 # more cells (slower) + python scripts/bench_ligrec.py --n-runs 5 # average over 5 runs + python scripts/bench_ligrec.py --n-jobs 4 # 4 workers (main only) + python scripts/bench_ligrec.py --no-cache # rebuild data from scratch + +Defaults are calibrated to ~30s per run on Apple M-series (1 core): + 30 000 cells, 2 000 genes, 25 clusters, 6 400 interactions, 1 000 perms. + +The prepared AnnData + interactions are cached under .pytest_cache/ +so repeated runs skip the (slow) data-generation step. +""" + +from __future__ import annotations + +import argparse +import hashlib +import inspect +import pickle +import time +import warnings +from itertools import product +from pathlib import Path + +warnings.filterwarnings("ignore", category=FutureWarning) +warnings.filterwarnings("ignore", message=".*ImplicitModificationWarning.*") +warnings.filterwarnings("ignore", message=".*Transforming to str index.*") + +import numpy as np +import pandas as pd +from anndata import AnnData + +from squidpy.gr import ligrec + +CACHE_DIR = Path(".pytest_cache") / "bench_ligrec" + + +def _cache_key( + n_cells: int, + n_genes: int, + n_clusters: int, + n_interaction_genes: int, +) -> str: + tag = f"{n_cells}_{n_genes}_{n_clusters}_{n_interaction_genes}" + return hashlib.sha256(tag.encode()).hexdigest()[:16] + + +def _build_adata( + n_cells: int, + n_genes: int, + n_clusters: int, + n_interaction_genes: int, + use_cache: bool, +) -> tuple[AnnData, list[tuple[str, str]]]: + key = _cache_key(n_cells, n_genes, n_clusters, n_interaction_genes) + cache_path = CACHE_DIR / f"{key}.pkl" + + if use_cache and cache_path.exists(): + print(f"Loading cached data from {cache_path}", flush=True) + with open(cache_path, "rb") as f: + adata, interactions = pickle.load(f) + print( + f" cells={adata.n_obs}, genes={adata.n_vars}, " + f"clusters={len(adata.obs['cluster'].cat.categories)}, " + f"interactions={len(interactions)}", + flush=True, + ) + return adata, interactions + + print("Building synthetic AnnData...", flush=True) + rng = np.random.default_rng(42) + X = rng.random((n_cells, n_genes)) + cluster_labels = rng.choice( + [f"c{i}" for i in range(n_clusters)], size=n_cells + ) + obs = pd.DataFrame({"cluster": pd.Categorical(cluster_labels)}) + var = pd.DataFrame(index=[f"G{i}" for i in range(n_genes)]) + adata = AnnData(X, obs=obs, var=var) + adata.raw = adata.copy() + + igenes = list(adata.var_names[:n_interaction_genes]) + interactions = list(product(igenes, igenes)) + + print( + f" cells={n_cells}, genes={n_genes}, clusters={n_clusters}, " + f"interaction_genes={n_interaction_genes}, " + f"interactions={len(interactions)}", + flush=True, + ) + + CACHE_DIR.mkdir(parents=True, exist_ok=True) + with open(cache_path, "wb") as f: + pickle.dump((adata, interactions), f, protocol=pickle.HIGHEST_PROTOCOL) + print(f" cached to {cache_path}", flush=True) + + return adata, interactions + + +def _run_once( + adata: AnnData, + interactions: list[tuple[str, str]], + n_perms: int, + extra_kwargs: dict, +) -> float: + t0 = time.perf_counter() + ligrec( + adata, + cluster_key="cluster", + interactions=interactions, + n_perms=n_perms, + copy=True, + seed=0, + use_raw=True, + show_progress_bar=False, + **extra_kwargs, + ) + return time.perf_counter() - t0 + + +def _warmup(adata: AnnData, interactions: list[tuple[str, str]]): + """Run a tiny ligrec call to trigger numba JIT compilation.""" + print("\nWarmup (compiling numba)...", flush=True) + small = adata[:50, :].copy() + small.raw = small.copy() + small_inter = interactions[:4] + ligrec( + small, + cluster_key="cluster", + interactions=small_inter, + n_perms=5, + copy=True, + seed=0, + use_raw=True, + show_progress_bar=False, + ) + print(" done.\n", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description="Benchmark ligrec()") + parser.add_argument( + "--n-cells", type=int, default=30000, + help="Number of cells (default 30000)", + ) + parser.add_argument( + "--n-genes", type=int, default=2000, + help="Total genes in AnnData (default 2000)", + ) + parser.add_argument( + "--n-clusters", type=int, default=25, + help="Number of clusters (default 25)", + ) + parser.add_argument( + "--n-interaction-genes", type=int, default=80, + help="Genes used in interactions; n^2 pairs (default 80 -> 6400)", + ) + parser.add_argument( + "--n-perms", type=int, default=1000, + help="Number of permutations (default 1000)", + ) + parser.add_argument( + "--n-runs", type=int, default=3, + help="Number of timed runs (default 3)", + ) + parser.add_argument( + "--n-jobs", type=int, default=6, + help="n_jobs to pass (default: omit, uses ligrec default)", + ) + parser.add_argument( + "--no-cache", action="store_true", + help="Rebuild data even if cache exists", + ) + args = parser.parse_args() + + + adata, interactions = _build_adata( + n_cells=args.n_cells, + n_genes=args.n_genes, + n_clusters=args.n_clusters, + n_interaction_genes=args.n_interaction_genes, + use_cache=not args.no_cache, + ) + + _warmup(adata, interactions) + + sig = inspect.signature(ligrec) + extra_kwargs: dict = {} + if "n_jobs" in sig.parameters and args.n_jobs is not None: + extra_kwargs["n_jobs"] = args.n_jobs + print(f"Passing n_jobs={args.n_jobs}") + + n_inter = len(interactions) + n_cls_pairs = len(adata.obs["cluster"].cat.categories) ** 2 + print( + f"Config: {args.n_cells} cells, {args.n_genes} genes, " + f"{args.n_clusters} clusters, {n_inter} interactions, " + f"{n_cls_pairs} cluster pairs, {args.n_perms} perms", + flush=True, + ) + print(f"Running ligrec() {args.n_runs} time(s)...\n", flush=True) + + times = [] + for i in range(args.n_runs): + t = _run_once(adata, interactions, args.n_perms, extra_kwargs) + times.append(t) + print(f" run {i + 1}: {t:.3f}s", flush=True) + + times_arr = np.array(times) + print(f"\nResults ({args.n_runs} runs):") + print(f" mean: {times_arr.mean():.3f}s") + print(f" median: {np.median(times_arr):.3f}s") + print(f" min: {times_arr.min():.3f}s") + print(f" max: {times_arr.max():.3f}s") + + +if __name__ == "__main__": + main() From 9fa3a53be64e9940b64fd82df95db98560210df9 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Sun, 22 Feb 2026 22:16:35 +0100 Subject: [PATCH 2/9] matmul approach --- scripts/bench_ligrec.py | 38 +---- src/squidpy/gr/_ligrec.py | 297 ++++++-------------------------------- 2 files changed, 42 insertions(+), 293 deletions(-) diff --git a/scripts/bench_ligrec.py b/scripts/bench_ligrec.py index a20c0d85..fe8f3bf4 100644 --- a/scripts/bench_ligrec.py +++ b/scripts/bench_ligrec.py @@ -20,7 +20,6 @@ import argparse import hashlib -import inspect import pickle import time import warnings @@ -105,7 +104,6 @@ def _run_once( adata: AnnData, interactions: list[tuple[str, str]], n_perms: int, - extra_kwargs: dict, ) -> float: t0 = time.perf_counter() ligrec( @@ -116,31 +114,10 @@ def _run_once( copy=True, seed=0, use_raw=True, - show_progress_bar=False, - **extra_kwargs, ) return time.perf_counter() - t0 -def _warmup(adata: AnnData, interactions: list[tuple[str, str]]): - """Run a tiny ligrec call to trigger numba JIT compilation.""" - print("\nWarmup (compiling numba)...", flush=True) - small = adata[:50, :].copy() - small.raw = small.copy() - small_inter = interactions[:4] - ligrec( - small, - cluster_key="cluster", - interactions=small_inter, - n_perms=5, - copy=True, - seed=0, - use_raw=True, - show_progress_bar=False, - ) - print(" done.\n", flush=True) - - def main(): parser = argparse.ArgumentParser(description="Benchmark ligrec()") parser.add_argument( @@ -167,17 +144,12 @@ def main(): "--n-runs", type=int, default=3, help="Number of timed runs (default 3)", ) - parser.add_argument( - "--n-jobs", type=int, default=6, - help="n_jobs to pass (default: omit, uses ligrec default)", - ) parser.add_argument( "--no-cache", action="store_true", help="Rebuild data even if cache exists", ) args = parser.parse_args() - adata, interactions = _build_adata( n_cells=args.n_cells, n_genes=args.n_genes, @@ -186,14 +158,6 @@ def main(): use_cache=not args.no_cache, ) - _warmup(adata, interactions) - - sig = inspect.signature(ligrec) - extra_kwargs: dict = {} - if "n_jobs" in sig.parameters and args.n_jobs is not None: - extra_kwargs["n_jobs"] = args.n_jobs - print(f"Passing n_jobs={args.n_jobs}") - n_inter = len(interactions) n_cls_pairs = len(adata.obs["cluster"].cat.categories) ** 2 print( @@ -206,7 +170,7 @@ def main(): times = [] for i in range(args.n_runs): - t = _run_once(adata, interactions, args.n_perms, extra_kwargs) + t = _run_once(adata, interactions, args.n_perms) times.append(t) print(f" run {i + 1}: {t:.3f}s", flush=True) diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index a4beecd8..a6285d9f 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -5,7 +5,6 @@ from abc import ABC from collections import namedtuple from collections.abc import Iterable, Mapping, Sequence -from functools import partial from itertools import product from types import MappingProxyType from typing import TYPE_CHECKING, Any, Literal, TypeAlias @@ -20,7 +19,7 @@ from squidpy._constants._constants import ComplexPolicy, CorrAxis from squidpy._constants._pkg_constants import Key from squidpy._docs import d, inject_docs -from squidpy._utils import NDArrayA, Signal, SigQueue, _get_n_cores, parallelize +from squidpy._utils import NDArrayA from squidpy.gr._utils import ( _assert_categorical_obs, _assert_positive, @@ -42,102 +41,6 @@ TempResult = namedtuple("TempResult", ["means", "pvalues"]) -_template = """ -from __future__ import annotations - -from numba import njit, prange -import numpy as np - -@njit(parallel={parallel}, cache=False, fastmath=False) -def _test_{n_cls}_{ret_means}_{parallel}( - interactions: NDArrayA[np.uint32], - interaction_clusters: NDArrayA[np.uint32], - data: NDArrayA[np.float64], - clustering: NDArrayA[np.uint32], - mean: NDArrayA[np.float64], - mask: NDArrayA[np.bool_], - res: NDArrayA[np.float64], - {args} -) -> None: - - {init} - {loop} - {finalize} - - for i in prange(len(interactions)): - rec, lig = interactions[i] - for j in prange(len(interaction_clusters)): - c1, c2 = interaction_clusters[j] - m1, m2 = mean[rec, c1], mean[lig, c2] - - if np.isnan(res[i, j]): - continue - - if m1 > 0 and m2 > 0: - {set_means} - if mask[rec, c1] and mask[lig, c2]: - # both rec, lig are sufficiently expressed in c1, c2 - res[i, j] += (groups[c1, rec] + groups[c2, lig]) > (m1 + m2) - else: - res[i, j] = np.nan - else: - # res_means is initialized with 0s - res[i, j] = np.nan -""" - - -def _create_template(n_cls: int, return_means: bool = False, parallel: bool = True) -> str: - if n_cls <= 0: - raise ValueError(f"Expected number of clusters to be positive, found `{n_cls}`.") - - rng = range(n_cls) - init = "".join( - f""" - g{i} = np.zeros((data.shape[1],), dtype=np.float64); s{i} = 0""" - for i in rng - ) - init += """ - error = False - """ - - loop_body = """ - if cl == 0: - g0 += data[row] - s0 += 1""" - loop_body = loop_body + "".join( - f""" - elif cl == {i}: - g{i} += data[row] - s{i} += 1""" - for i in range(1, n_cls) - ) - loop = f""" - for row in prange(data.shape[0]): - cl = clustering[row] - {loop_body} - else: - error = True - """ - finalize = ", ".join(f"g{i} / s{i}" for i in rng) - finalize = f"groups = np.stack(({finalize}))" - - if return_means: - args = "res_means: NDArrayA, # [np.float64]" - set_means = "res_means[i, j] = (m1 + m2) / 2.0" - else: - args = set_means = "" - - return _template.format( - n_cls=n_cls, - parallel=bool(parallel), - ret_means=int(return_means), - args=args, - init=init, - loop=loop, - finalize=finalize, - set_means=set_means, - ) - def _fdr_correct( pvals: pd.DataFrame, @@ -326,8 +229,6 @@ def test( alpha: float = 0.05, copy: bool = False, key_added: str | None = None, - numba_parallel: bool | None = None, - **kwargs: Any, ) -> Mapping[str, pd.DataFrame] | None: """ Perform the permutation test as described in :cite:`cellphonedb`. @@ -355,9 +256,6 @@ def test( key_added Key in :attr:`anndata.AnnData.uns` where the result is stored if ``copy = False``. If `None`, ``'{{cluster_key}}_ligrec'`` will be used. - %(numba_parallel)s - %(parallelize)s - Returns ------- %(ligrec_test_returns)s @@ -409,10 +307,9 @@ def test( # much faster than applymap (tested on 1M interactions) interactions_ = np.vectorize(lambda g: gene_mapper[g])(interactions.values) - n_jobs = _get_n_cores(kwargs.pop("n_jobs", None)) start = logg.info( f"Running `{n_perms}` permutations on `{len(interactions)}` interactions " - f"and `{len(clusters)}` cluster combinations using `{n_jobs}` core(s)" + f"and `{len(clusters)}` cluster combinations" ) res = _analysis( data, @@ -421,9 +318,6 @@ def test( threshold=threshold, n_perms=n_perms, seed=seed, - n_jobs=n_jobs, - numba_parallel=numba_parallel, - **kwargs, ) index = pd.MultiIndex.from_frame(interactions, names=[SOURCE, TARGET]) columns = pd.MultiIndex.from_tuples(clusters, names=["cluster_1", "cluster_2"]) @@ -454,6 +348,7 @@ def test( return res _save_data(self._adata, attr="uns", key=Key.uns.ligrec(cluster_key, key_added), data=res, time=start) + return None def _trim_data(self) -> None: """Subset genes :attr:`_data` to those present in interactions.""" @@ -672,7 +567,6 @@ def ligrec( corr_axis=corr_axis, copy=copy, key_added=key_added, - **kwargs, ) ) @@ -685,15 +579,10 @@ def _analysis( threshold: float = 0.1, n_perms: int = 1000, seed: int | None = None, - n_jobs: int = 1, - numba_parallel: bool | None = None, - **kwargs: Any, ) -> TempResult: """ Run the analysis as described in :cite:`cellphonedb`. - This function runs the mean, percent and shuffled analysis. - Parameters ---------- data @@ -706,12 +595,6 @@ def _analysis( Percentage threshold for removing lowly expressed genes in clusters. %(n_perms)s %(seed)s - n_jobs - Number of parallel jobs to launch. - numba_parallel - Whether to use :func:`numba.prange` or not. If `None`, it's determined automatically. - kwargs - Keyword arguments for :func:`squidpy._utils.parallelize`, such as ``n_jobs`` or ``backend``. Returns ------- @@ -720,145 +603,47 @@ def _analysis( - `'means'` - array of shape `(n_interactions, n_interaction_clusters)` containing the means. - `'pvalues'` - array of shape `(n_interactions, n_interaction_clusters)` containing the p-values. """ - - def extractor(res: Sequence[TempResult]) -> TempResult: - assert len(res) == n_jobs, f"Expected to find `{n_jobs}` results, found `{len(res)}`." - - meanss: list[NDArrayA] = [r.means for r in res if r.means is not None] - assert len(meanss) == 1, f"Only `1` job should've calculated the means, but found `{len(meanss)}`." - means = meanss[0] - if TYPE_CHECKING: - assert isinstance(means, np.ndarray) - - pvalues = np.sum([r.pvalues for r in res if r.pvalues is not None], axis=0) / float(n_perms) - assert means.shape == pvalues.shape, f"Means and p-values differ in shape: `{means.shape}`, `{pvalues.shape}`." - - return TempResult(means=means, pvalues=pvalues) - - clustering = np.array(data["clusters"].values, dtype=np.int32) - # densify the data earlier to avoid concatenating sparse arrays - # with multiple fill values: '[0.0, nan]' (which leads to PerformanceWarning) - data = data.astype({c: np.float64 for c in data.columns if c != "clusters"}) - groups = data.groupby("clusters", observed=True) - - mean = groups.mean().values.T # (n_genes, n_clusters) - # see https://github.com/scverse/squidpy/pull/991#issuecomment-2888506296 - # for why we need to cast to int64 here - mask = groups.apply( - lambda c: ((c > 0).astype(np.int64).sum() / len(c)) >= threshold - ).values.T # (n_genes, n_clusters) - - # (n_cells, n_genes) - data = np.array(data[data.columns.difference(["clusters"])].values, dtype=np.float64, order="C") - # all 3 should be C contiguous - return parallelize( # type: ignore[no-any-return] - _analysis_helper, - np.arange(n_perms, dtype=np.int32).tolist(), - n_jobs=n_jobs, - unit="permutation", - extractor=extractor, - **kwargs, - )( - data, - mean, - mask, - interactions, - interaction_clusters=interaction_clusters, - clustering=clustering, - seed=seed, - numba_parallel=numba_parallel, + clustering = np.asarray(data["clusters"].values, dtype=np.int32) + data_arr = data.drop(columns="clusters").values.astype(np.float64) + n_cells, n_genes = data_arr.shape + n_cls = int(clustering.max()) + 1 + + arange_cells = np.arange(n_cells) + onehot = np.zeros((n_cells, n_cls), dtype=np.float64) + onehot[arange_cells, clustering] = 1.0 + counts = onehot.sum(axis=0) + mean_obs = (onehot.T @ data_arr) / np.maximum(counts[:, None], 1) # (n_cls, n_genes) + + frac_expr = (onehot.T @ (data_arr > 0).astype(np.float64)) / np.maximum(counts[:, None], 1) + mask = frac_expr >= threshold # (n_cls, n_genes) + + rec = interactions[:, 0] + lig = interactions[:, 1] + c1 = interaction_clusters[:, 0] + c2 = interaction_clusters[:, 1] + + obs_score = mean_obs[c1, :][:, rec].T + mean_obs[c2, :][:, lig].T # (n_inter, n_cpairs) + valid = ( + (mean_obs[c1, :][:, rec].T > 0) + & (mean_obs[c2, :][:, lig].T > 0) + & mask[c1, :][:, rec].T + & mask[c2, :][:, lig].T ) + res_means = np.where(valid, obs_score / 2.0, np.nan) -def _analysis_helper( - perms: NDArrayA, - data: NDArrayA, - mean: NDArrayA, - mask: NDArrayA, - interactions: NDArrayA, - interaction_clusters: NDArrayA, - clustering: NDArrayA, - seed: int | None = None, - numba_parallel: bool | None = None, - queue: SigQueue | None = None, -) -> TempResult: - """ - Run the results of mean, percent and shuffled analysis. - - Parameters - ---------- - perms - Permutation indices. Only used to set the ``seed``. - data - Array of shape `(n_cells, n_genes)`. - mean - Array of shape `(n_genes, n_clusters)` representing mean expression per cluster. - mask - Array of shape `(n_genes, n_clusters)` containing `True` if the a gene within a cluster is - expressed at least in ``threshold`` percentage of cells. - interactions - Array of shape `(n_interactions, 2)`. - interaction_clusters - Array of shape `(n_interaction_clusters, 2)`. - clustering - Array of shape `(n_cells,)` containing the original clustering. - seed - Random seed for :class:`numpy.random.RandomState`. - numba_parallel - Whether to use :func:`numba.prange` or not. If `None`, it's determined automatically. - queue - Signalling queue to update progress bar. - - Returns - ------- - Tuple of the following format: + rng = np.random.default_rng(seed) + pval_counts = np.zeros(obs_score.shape, dtype=np.int64) - - `'means'` - array of shape `(n_interactions, n_interaction_clusters)` containing the true test - statistic. It is `None` if ``min(perms)!=0`` so that only 1 worker calculates it. - - `'pvalues'` - array of shape `(n_interactions, n_interaction_clusters)` containing `np.sum(T0 > T)` - where `T0` is the test statistic under null hypothesis and `T` is the true test statistic. - """ - rs = np.random.RandomState(None if seed is None else perms[0] + seed) + for _ in range(n_perms): + perm = rng.permutation(clustering) + oh = np.zeros((n_cells, n_cls), dtype=np.float64) + oh[arange_cells, perm] = 1.0 + groups = (oh.T @ data_arr) / np.maximum(oh.sum(axis=0)[:, None], 1) + shuf_score = groups[c1, :][:, rec].T + groups[c2, :][:, lig].T + pval_counts += (shuf_score > obs_score) & valid - clustering = clustering.copy() - n_cls = mean.shape[1] - return_means = np.min(perms) == 0 + pvalues = pval_counts.astype(np.float64) / n_perms + pvalues[~valid] = np.nan - # ideally, these would be both sparse array, but there is no numba impl. (sparse.COO is read-only and very limited) - # keep it f64, because we're setting NaN - res = np.zeros((len(interactions), len(interaction_clusters)), dtype=np.float64) - numba_parallel = ( - (np.prod(res.shape) >= 2**20 or clustering.shape[0] >= 2**15) if numba_parallel is None else numba_parallel # type: ignore[assignment] - ) - - fn_key = f"_test_{n_cls}_{int(return_means)}_{bool(numba_parallel)}" - if fn_key not in globals(): - exec( - compile(_create_template(n_cls, return_means=return_means, parallel=numba_parallel), "", "exec"), # type: ignore[arg-type] - globals(), - ) - _test = globals()[fn_key] - - if return_means: - res_means: NDArrayA | None = np.zeros((len(interactions), len(interaction_clusters)), dtype=np.float64) - test = partial(_test, res_means=res_means) - else: - res_means = None - test = _test - - for _ in perms: - rs.shuffle(clustering) - error = test(interactions, interaction_clusters, data, clustering, mean, mask, res=res) - if error: - raise ValueError("In the execution of the numba function, an unhandled case was encountered. ") - # This is mainly to avoid a numba warning - # Otherwise, the numba function wouldn't be - # executed in parallel - # See: https://github.com/scverse/squidpy/issues/994 - if queue is not None: - queue.put(Signal.UPDATE) - - if queue is not None: - queue.put(Signal.FINISH) - - return TempResult(means=res_means, pvalues=res) + return TempResult(means=res_means, pvalues=pvalues) From f8b701d0c4978e9d0567d680c73500abfb193da3 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Sun, 22 Feb 2026 22:36:06 +0100 Subject: [PATCH 3/9] wip --- scripts/bench_ligrec.py | 7 ++ src/squidpy/gr/_ligrec.py | 134 ++++++++++++++++++++++++++++++++----- tests/graph/test_ligrec.py | 2 +- 3 files changed, 124 insertions(+), 19 deletions(-) diff --git a/scripts/bench_ligrec.py b/scripts/bench_ligrec.py index fe8f3bf4..3653be92 100644 --- a/scripts/bench_ligrec.py +++ b/scripts/bench_ligrec.py @@ -158,6 +158,13 @@ def main(): use_cache=not args.no_cache, ) + print("\nWarmup (JIT compile)...", flush=True) + small = adata[:50, :].copy() + small.raw = small.copy() + ligrec(small, cluster_key="cluster", interactions=interactions[:4], + n_perms=5, copy=True, seed=0, use_raw=True) + print(" done.\n", flush=True) + n_inter = len(interactions) n_cls_pairs = len(adata.obs["cluster"].cat.categories) ** 2 print( diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index a6285d9f..283816a6 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -8,10 +8,11 @@ from itertools import product from types import MappingProxyType from typing import TYPE_CHECKING, Any, Literal, TypeAlias - +import numba import numpy as np import pandas as pd from anndata import AnnData +from numba import njit, prange from scanpy import logging as logg from scipy.sparse import csc_matrix from spatialdata import SpatialData @@ -571,6 +572,96 @@ def ligrec( ) +@njit(cache=True) +def _permutation_test_chunk( + data: NDArrayA, + clustering: NDArrayA, + inv_counts: NDArrayA, + mean_obs: NDArrayA, + interactions: NDArrayA, + interaction_clusters: NDArrayA, + valid: NDArrayA, + perm_start: int, + perm_end: int, + seed: int, + local_counts: NDArrayA, +) -> None: + """Run a chunk of permutations sequentially, accumulating into local_counts.""" + n_cells = data.shape[0] + n_genes = data.shape[1] + n_cls = mean_obs.shape[0] + n_inter = interactions.shape[0] + n_cpairs = interaction_clusters.shape[0] + + for perm_idx in range(perm_start, perm_end): + np.random.seed(seed + perm_idx) + perm = clustering.copy() + np.random.shuffle(perm) + + groups = np.zeros((n_cls, n_genes), dtype=np.float64) + for cell in range(n_cells): + cl = perm[cell] + for g in range(n_genes): + groups[cl, g] += data[cell, g] + for k in range(n_cls): + inv_c = inv_counts[k] + for g in range(n_genes): + groups[k, g] *= inv_c + + for i in range(n_inter): + r = interactions[i, 0] + l = interactions[i, 1] + for j in range(n_cpairs): + if valid[i, j]: + a = interaction_clusters[j, 0] + b = interaction_clusters[j, 1] + shuf = groups[a, r] + groups[b, l] + obs = mean_obs[a, r] + mean_obs[b, l] + if shuf > obs: + local_counts[i, j] += 1 + + +@njit(parallel=True, cache=True) +def _permutation_test( + data: NDArrayA, + clustering: NDArrayA, + inv_counts: NDArrayA, + mean_obs: NDArrayA, + mask: NDArrayA, + interactions: NDArrayA, + interaction_clusters: NDArrayA, + valid: NDArrayA, + n_perms: int, + seed: int, + pval_counts: NDArrayA, +) -> None: + """Distribute permutations across threads, each with a local accumulator.""" + n_inter = interactions.shape[0] + n_cpairs = interaction_clusters.shape[0] + n_threads = numba.get_num_threads() + if n_threads <= 0: + n_threads = n_perms + n_threads = min(n_threads, n_perms) + chunk = (n_perms + n_threads - 1) // n_threads + + thread_counts = np.zeros((n_threads, n_inter, n_cpairs), dtype=np.int64) + + for t in prange(n_threads): + start = t * chunk + end = min(start + chunk, n_perms) + if start < end: + _permutation_test_chunk( + data, clustering, inv_counts, mean_obs, + interactions, interaction_clusters, valid, + start, end, seed, thread_counts[t], + ) + + for t in range(n_threads): + for i in range(n_inter): + for j in range(n_cpairs): + pval_counts[i, j] += thread_counts[t, i, j] + + @d.dedent def _analysis( data: pd.DataFrame, @@ -603,8 +694,10 @@ def _analysis( - `'means'` - array of shape `(n_interactions, n_interaction_clusters)` containing the means. - `'pvalues'` - array of shape `(n_interactions, n_interaction_clusters)` containing the p-values. """ - clustering = np.asarray(data["clusters"].values, dtype=np.int32) - data_arr = data.drop(columns="clusters").values.astype(np.float64) + clustering = np.ascontiguousarray(data["clusters"].values, dtype=np.int32) + data_arr = np.ascontiguousarray( + data.drop(columns="clusters").values, dtype=np.float64 + ) n_cells, n_genes = data_arr.shape n_cls = int(clustering.max()) + 1 @@ -612,36 +705,41 @@ def _analysis( onehot = np.zeros((n_cells, n_cls), dtype=np.float64) onehot[arange_cells, clustering] = 1.0 counts = onehot.sum(axis=0) - mean_obs = (onehot.T @ data_arr) / np.maximum(counts[:, None], 1) # (n_cls, n_genes) + inv_counts = np.ascontiguousarray(1.0 / np.maximum(counts, 1)) + mean_obs = np.ascontiguousarray( + (onehot.T @ data_arr) * inv_counts[:, None] + ) # (n_cls, n_genes) - frac_expr = (onehot.T @ (data_arr > 0).astype(np.float64)) / np.maximum(counts[:, None], 1) - mask = frac_expr >= threshold # (n_cls, n_genes) + frac_expr = (onehot.T @ (data_arr > 0).astype(np.float64)) * inv_counts[:, None] + mask = np.ascontiguousarray(frac_expr >= threshold) # (n_cls, n_genes) + interactions = np.ascontiguousarray(interactions, dtype=np.int32) + interaction_clusters = np.ascontiguousarray(interaction_clusters, dtype=np.int32) rec = interactions[:, 0] lig = interactions[:, 1] c1 = interaction_clusters[:, 0] c2 = interaction_clusters[:, 1] - obs_score = mean_obs[c1, :][:, rec].T + mean_obs[c2, :][:, lig].T # (n_inter, n_cpairs) - valid = ( + obs_score = mean_obs[c1, :][:, rec].T + mean_obs[c2, :][:, lig].T + valid = np.ascontiguousarray( (mean_obs[c1, :][:, rec].T > 0) & (mean_obs[c2, :][:, lig].T > 0) & mask[c1, :][:, rec].T & mask[c2, :][:, lig].T ) - res_means = np.where(valid, obs_score / 2.0, np.nan) - rng = np.random.default_rng(seed) - pval_counts = np.zeros(obs_score.shape, dtype=np.int64) + n_inter = len(rec) + n_cpairs = len(c1) + pval_counts = np.zeros((n_inter, n_cpairs), dtype=np.int64) + if seed is None: + seed = 0 - for _ in range(n_perms): - perm = rng.permutation(clustering) - oh = np.zeros((n_cells, n_cls), dtype=np.float64) - oh[arange_cells, perm] = 1.0 - groups = (oh.T @ data_arr) / np.maximum(oh.sum(axis=0)[:, None], 1) - shuf_score = groups[c1, :][:, rec].T + groups[c2, :][:, lig].T - pval_counts += (shuf_score > obs_score) & valid + _permutation_test( + data_arr, clustering, inv_counts, mean_obs, mask, + interactions, interaction_clusters, valid, + n_perms, seed, pval_counts, numba.get_num_threads(), + ) pvalues = pval_counts.astype(np.float64) / n_perms pvalues[~valid] = np.nan diff --git a/tests/graph/test_ligrec.py b/tests/graph/test_ligrec.py index 099ecb1b..214fcc41 100644 --- a/tests/graph/test_ligrec.py +++ b/tests/graph/test_ligrec.py @@ -402,7 +402,7 @@ def test_logging(self, adata: AnnData, interactions: Interactions_t, capsys): assert "DEBUG: Creating all gene combinations within complexes" in err assert "DEBUG: Removing interactions with no genes in the data" in err assert "DEBUG: Removing genes not in any interaction" in err - assert "Running `5` permutations on `25` interactions and `25` cluster combinations using `2` core(s)" in err + assert "Running `5` permutations on `25` interactions and `25` cluster combinations" in err assert "Adding `adata.uns['ligrec_test']`" in err def test_non_uniqueness(self, adata: AnnData, interactions: Interactions_t): From 28cabbe2ce47ae0c3e14a9bbbd1921e787a463f3 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Sun, 22 Feb 2026 22:49:35 +0100 Subject: [PATCH 4/9] ligrec changes --- src/squidpy/gr/_ligrec.py | 28 +++++++--- tests/conftest.py | 8 --- tests/graph/test_ligrec.py | 108 ++++++++++++------------------------- 3 files changed, 55 insertions(+), 89 deletions(-) diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index 283816a6..3855873f 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -538,7 +538,13 @@ def ligrec( copy: bool = False, key_added: str | None = None, gene_symbols: str | None = None, - **kwargs: Any, + n_perms: int = 1000, + seed: int | None = None, + clusters: Cluster_t | None = None, + alpha: float = 0.05, + interactions_params: Mapping[str, Any] = MappingProxyType({}), + transmitter_params: Mapping[str, Any] = MappingProxyType({"categories": "ligand"}), + receiver_params: Mapping[str, Any] = MappingProxyType({"categories": "receptor"}), ) -> Mapping[str, pd.DataFrame] | None: """ %(PT_test.full_desc)s @@ -560,12 +566,22 @@ def ligrec( with _genesymbols(adata, key=gene_symbols, use_raw=use_raw, make_unique=False): return ( # type: ignore[no-any-return] PermutationTest(adata, use_raw=use_raw) - .prepare(interactions, complex_policy=complex_policy, **kwargs) + .prepare( + interactions, + complex_policy=complex_policy, + interactions_params=interactions_params, + transmitter_params=transmitter_params, + receiver_params=receiver_params, + ) .test( cluster_key=cluster_key, + clusters=clusters, + n_perms=n_perms, threshold=threshold, + seed=seed, corr_method=corr_method, corr_axis=corr_axis, + alpha=alpha, copy=copy, key_added=key_added, ) @@ -721,13 +737,13 @@ def _analysis( c2 = interaction_clusters[:, 1] obs_score = mean_obs[c1, :][:, rec].T + mean_obs[c2, :][:, lig].T + nonzero = (mean_obs[c1, :][:, rec].T > 0) & (mean_obs[c2, :][:, lig].T > 0) valid = np.ascontiguousarray( - (mean_obs[c1, :][:, rec].T > 0) - & (mean_obs[c2, :][:, lig].T > 0) + nonzero & mask[c1, :][:, rec].T & mask[c2, :][:, lig].T ) - res_means = np.where(valid, obs_score / 2.0, np.nan) + res_means = np.where(nonzero, obs_score / 2.0, 0.0) n_inter = len(rec) n_cpairs = len(c1) @@ -738,7 +754,7 @@ def _analysis( _permutation_test( data_arr, clustering, inv_counts, mean_obs, mask, interactions, interaction_clusters, valid, - n_perms, seed, pval_counts, numba.get_num_threads(), + n_perms, seed, pval_counts, ) pvalues = pval_counts.astype(np.float64) / n_perms diff --git a/tests/conftest.py b/tests/conftest.py index 1dfe7b0a..5ea80564 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -264,12 +264,6 @@ def complexes(adata: AnnData) -> Sequence[tuple[str, str]]: ] -@pytest.fixture(scope="session") -def ligrec_no_numba() -> Mapping[str, pd.DataFrame]: - with open("tests/_data/ligrec_no_numba.pickle", "rb") as fin: - data = pickle.load(fin) - return {"means": data[0], "pvalues": data[1], "metadata": data[2]} - @pytest.fixture(scope="session") def ligrec_result() -> Mapping[str, pd.DataFrame]: @@ -280,8 +274,6 @@ def ligrec_result() -> Mapping[str, pd.DataFrame]: "leiden", interactions=interactions, n_perms=25, - n_jobs=1, - show_progress_bar=False, copy=True, seed=0, ) diff --git a/tests/graph/test_ligrec.py b/tests/graph/test_ligrec.py index 214fcc41..d50bbec5 100644 --- a/tests/graph/test_ligrec.py +++ b/tests/graph/test_ligrec.py @@ -1,10 +1,11 @@ from __future__ import annotations import sys -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from itertools import product -from time import time + from typing import TYPE_CHECKING +import numba import numpy as np import pandas as pd @@ -167,8 +168,6 @@ def test_fdr_axis_works(self, adata: AnnData, interactions: Interactions_t): n_perms=5, corr_axis="clusters", seed=42, - n_jobs=1, - show_progress_bar=False, copy=True, ) ri = ligrec( @@ -177,8 +176,6 @@ def test_fdr_axis_works(self, adata: AnnData, interactions: Interactions_t): interactions=interactions, n_perms=5, corr_axis="interactions", - n_jobs=1, - show_progress_bar=False, seed=42, copy=True, ) @@ -191,7 +188,7 @@ def test_fdr_axis_works(self, adata: AnnData, interactions: Interactions_t): def test_inplace_default_key(self, adata: AnnData, interactions: Interactions_t): key = Key.uns.ligrec(_CK) assert key not in adata.uns - res = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=False, show_progress_bar=False) + res = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=False) assert res is None assert isinstance(adata.uns[key], dict) @@ -204,8 +201,7 @@ def test_inplace_default_key(self, adata: AnnData, interactions: Interactions_t) def test_inplace_key_added(self, adata: AnnData, interactions: Interactions_t): assert "foobar" not in adata.uns res = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=False, key_added="foobar", show_progress_bar=False - ) + adata, _CK, interactions=interactions, n_perms=5, copy=False, key_added="foobar" ) assert res is None assert isinstance(adata.uns["foobar"], dict) @@ -218,8 +214,7 @@ def test_inplace_key_added(self, adata: AnnData, interactions: Interactions_t): def test_return_no_write(self, adata: AnnData, interactions: Interactions_t): assert "foobar" not in adata.uns r = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=True, key_added="foobar", show_progress_bar=False - ) + adata, _CK, interactions=interactions, n_perms=5, copy=True, key_added="foobar" ) assert "foobar" not in adata.uns assert len(r) == 3 @@ -235,7 +230,7 @@ def test_pvals_in_correct_range(self, adata: AnnData, interactions: Interactions interactions=interactions, n_perms=5, copy=True, - show_progress_bar=False, + corr_method=fdr_method, threshold=0, ) @@ -247,7 +242,7 @@ def test_pvals_in_correct_range(self, adata: AnnData, interactions: Interactions assert np.nanmin(r["pvalues"].values) >= 0, np.nanmin(r["pvalues"].values) def test_result_correct_index(self, adata: AnnData, interactions: Interactions_t): - r = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=True, show_progress_bar=False) + r = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=True) np.testing.assert_array_equal(r["means"].index, r["pvalues"].index) np.testing.assert_array_equal(r["pvalues"].index, r["metadata"].index) @@ -261,7 +256,7 @@ def test_result_is_sparse(self, adata: AnnData, interactions: Interactions_t): if TYPE_CHECKING: assert isinstance(interactions, pd.DataFrame) interactions["metadata"] = "foo" - r = ligrec(adata, _CK, interactions=interactions, n_perms=5, seed=2, copy=True, show_progress_bar=False) + r = ligrec(adata, _CK, interactions=interactions, n_perms=5, seed=2, copy=True) assert r["means"].sparse.density <= 0.15 assert r["pvalues"].sparse.density <= 0.95 @@ -272,17 +267,14 @@ def test_result_is_sparse(self, adata: AnnData, interactions: Interactions_t): np.testing.assert_array_equal(r["metadata"].columns, ["metadata"]) np.testing.assert_array_equal(r["metadata"]["metadata"], interactions["metadata"]) - @pytest.mark.parametrize("n_jobs", [1, 2]) - def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_t, n_jobs: int): + def test_reproducibility(self, adata: AnnData, interactions: Interactions_t): r1 = ligrec( adata, _CK, interactions=interactions, n_perms=25, copy=True, - show_progress_bar=False, seed=42, - n_jobs=n_jobs, ) r2 = ligrec( adata, @@ -290,9 +282,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ interactions=interactions, n_perms=25, copy=True, - show_progress_bar=False, seed=42, - n_jobs=n_jobs, ) r3 = ligrec( adata, @@ -300,9 +290,7 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ interactions=interactions, n_perms=25, copy=True, - show_progress_bar=False, seed=43, - n_jobs=n_jobs, ) assert r1 is not r2 @@ -313,39 +301,6 @@ def test_reproducibility_cores(self, adata: AnnData, interactions: Interactions_ assert not np.allclose(r3["pvalues"], r1["pvalues"]) assert not np.allclose(r3["pvalues"], r2["pvalues"]) - def test_reproducibility_numba_parallel_off(self, adata: AnnData, interactions: Interactions_t): - t1 = time() - r1 = ligrec( - adata, - _CK, - interactions=interactions, - n_perms=25, - copy=True, - show_progress_bar=False, - seed=42, - numba_parallel=False, - ) - t1 = time() - t1 - - t2 = time() - r2 = ligrec( - adata, - _CK, - interactions=interactions, - n_perms=25, - copy=True, - show_progress_bar=False, - seed=42, - numba_parallel=True, - ) - t2 = time() - t2 - - assert r1 is not r2 - # for such a small data, overhead from parallelization is too high - assert t1 <= t2, (t1, t2) - np.testing.assert_allclose(r1["means"], r2["means"]) - np.testing.assert_allclose(r1["pvalues"], r2["pvalues"]) - def test_paul15_correct_means(self, paul15: AnnData, paul15_means: pd.DataFrame): res = ligrec( paul15, @@ -353,31 +308,36 @@ def test_paul15_correct_means(self, paul15: AnnData, paul15_means: pd.DataFrame) interactions=list(paul15_means.index.to_list()), corr_method=None, copy=True, - show_progress_bar=False, + threshold=0.01, seed=0, n_perms=1, - n_jobs=1, ) np.testing.assert_array_equal(res["means"].index, paul15_means.index) np.testing.assert_array_equal(res["means"].columns, paul15_means.columns) np.testing.assert_allclose(res["means"].values, paul15_means.values) - def test_reproducibility_numba_off( - self, adata: AnnData, interactions: Interactions_t, ligrec_no_numba: Mapping[str, pd.DataFrame] - ): - r = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=True, show_progress_bar=False, seed=42, n_jobs=1 + def test_reproducibility_single_thread(self, adata: AnnData, interactions: Interactions_t): + + old_threads = numba.get_num_threads() + try: + numba.set_num_threads(1) + r1 = ligrec( + adata, _CK, interactions=interactions, n_perms=5, copy=True, seed=42 + ) + finally: + numba.set_num_threads(old_threads) + + r2 = ligrec( + adata, _CK, interactions=interactions, n_perms=5, copy=True, seed=42 ) - np.testing.assert_array_equal(r["means"].index, ligrec_no_numba["means"].index) - np.testing.assert_array_equal(r["means"].columns, ligrec_no_numba["means"].columns) - np.testing.assert_array_equal(r["pvalues"].index, ligrec_no_numba["pvalues"].index) - np.testing.assert_array_equal(r["pvalues"].columns, ligrec_no_numba["pvalues"].columns) - np.testing.assert_allclose(r["means"], ligrec_no_numba["means"]) - np.testing.assert_allclose(r["pvalues"], ligrec_no_numba["pvalues"]) - np.testing.assert_array_equal(np.where(np.isnan(r["pvalues"])), np.where(np.isnan(ligrec_no_numba["pvalues"]))) + np.testing.assert_allclose(r1["means"], r2["means"]) + np.testing.assert_allclose(r1["pvalues"], r2["pvalues"]) + np.testing.assert_array_equal( + np.where(np.isnan(r1["pvalues"])), np.where(np.isnan(r2["pvalues"])) + ) def test_logging(self, adata: AnnData, interactions: Interactions_t, capsys): s.logfile = sys.stderr @@ -389,10 +349,9 @@ def test_logging(self, adata: AnnData, interactions: Interactions_t, capsys): interactions=interactions, n_perms=5, copy=False, - show_progress_bar=False, + complex_policy="all", key_added="ligrec_test", - n_jobs=2, ) err = capsys.readouterr().err @@ -418,9 +377,8 @@ def test_non_uniqueness(self, adata: AnnData, interactions: Interactions_t): interactions=interactions, n_perms=1, copy=True, - show_progress_bar=False, + seed=42, - numba_parallel=False, ) assert len(res["pvalues"]) == len(expected) @@ -428,7 +386,7 @@ def test_non_uniqueness(self, adata: AnnData, interactions: Interactions_t): @pytest.mark.xfail(reason="AnnData cannot handle writing MultiIndex") def test_writeable(self, adata: AnnData, interactions: Interactions_t, tmpdir): - ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=False, show_progress_bar=False, key_added="foo") + ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=False, key_added="foo") res = adata.uns["foo"] sc.write(tmpdir / "ligrec.h5ad", adata) @@ -448,7 +406,7 @@ def test_gene_symbols(self, adata: AnnData, use_raw: bool): n_perms=5, use_raw=use_raw, copy=True, - show_progress_bar=False, + gene_symbols="gene_ids", ) From 9a858a7901d417861fe368bfbe0115bae12b20bf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:51:49 +0000 Subject: [PATCH 5/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- scripts/bench_ligrec.py | 34 +++++++++++++++++++----------- src/squidpy/gr/_ligrec.py | 43 +++++++++++++++++++++++--------------- tests/conftest.py | 1 - tests/graph/test_ligrec.py | 26 ++++++----------------- 4 files changed, 54 insertions(+), 50 deletions(-) diff --git a/scripts/bench_ligrec.py b/scripts/bench_ligrec.py index 3653be92..0b4e22a9 100644 --- a/scripts/bench_ligrec.py +++ b/scripts/bench_ligrec.py @@ -74,9 +74,7 @@ def _build_adata( print("Building synthetic AnnData...", flush=True) rng = np.random.default_rng(42) X = rng.random((n_cells, n_genes)) - cluster_labels = rng.choice( - [f"c{i}" for i in range(n_clusters)], size=n_cells - ) + cluster_labels = rng.choice([f"c{i}" for i in range(n_clusters)], size=n_cells) obs = pd.DataFrame({"cluster": pd.Categorical(cluster_labels)}) var = pd.DataFrame(index=[f"G{i}" for i in range(n_genes)]) adata = AnnData(X, obs=obs, var=var) @@ -121,31 +119,44 @@ def _run_once( def main(): parser = argparse.ArgumentParser(description="Benchmark ligrec()") parser.add_argument( - "--n-cells", type=int, default=30000, + "--n-cells", + type=int, + default=30000, help="Number of cells (default 30000)", ) parser.add_argument( - "--n-genes", type=int, default=2000, + "--n-genes", + type=int, + default=2000, help="Total genes in AnnData (default 2000)", ) parser.add_argument( - "--n-clusters", type=int, default=25, + "--n-clusters", + type=int, + default=25, help="Number of clusters (default 25)", ) parser.add_argument( - "--n-interaction-genes", type=int, default=80, + "--n-interaction-genes", + type=int, + default=80, help="Genes used in interactions; n^2 pairs (default 80 -> 6400)", ) parser.add_argument( - "--n-perms", type=int, default=1000, + "--n-perms", + type=int, + default=1000, help="Number of permutations (default 1000)", ) parser.add_argument( - "--n-runs", type=int, default=3, + "--n-runs", + type=int, + default=3, help="Number of timed runs (default 3)", ) parser.add_argument( - "--no-cache", action="store_true", + "--no-cache", + action="store_true", help="Rebuild data even if cache exists", ) args = parser.parse_args() @@ -161,8 +172,7 @@ def main(): print("\nWarmup (JIT compile)...", flush=True) small = adata[:50, :].copy() small.raw = small.copy() - ligrec(small, cluster_key="cluster", interactions=interactions[:4], - n_perms=5, copy=True, seed=0, use_raw=True) + ligrec(small, cluster_key="cluster", interactions=interactions[:4], n_perms=5, copy=True, seed=0, use_raw=True) print(" done.\n", flush=True) n_inter = len(interactions) diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index 3855873f..61a0bc86 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -8,6 +8,7 @@ from itertools import product from types import MappingProxyType from typing import TYPE_CHECKING, Any, Literal, TypeAlias + import numba import numpy as np import pandas as pd @@ -667,9 +668,17 @@ def _permutation_test( end = min(start + chunk, n_perms) if start < end: _permutation_test_chunk( - data, clustering, inv_counts, mean_obs, - interactions, interaction_clusters, valid, - start, end, seed, thread_counts[t], + data, + clustering, + inv_counts, + mean_obs, + interactions, + interaction_clusters, + valid, + start, + end, + seed, + thread_counts[t], ) for t in range(n_threads): @@ -711,9 +720,7 @@ def _analysis( - `'pvalues'` - array of shape `(n_interactions, n_interaction_clusters)` containing the p-values. """ clustering = np.ascontiguousarray(data["clusters"].values, dtype=np.int32) - data_arr = np.ascontiguousarray( - data.drop(columns="clusters").values, dtype=np.float64 - ) + data_arr = np.ascontiguousarray(data.drop(columns="clusters").values, dtype=np.float64) n_cells, n_genes = data_arr.shape n_cls = int(clustering.max()) + 1 @@ -722,9 +729,7 @@ def _analysis( onehot[arange_cells, clustering] = 1.0 counts = onehot.sum(axis=0) inv_counts = np.ascontiguousarray(1.0 / np.maximum(counts, 1)) - mean_obs = np.ascontiguousarray( - (onehot.T @ data_arr) * inv_counts[:, None] - ) # (n_cls, n_genes) + mean_obs = np.ascontiguousarray((onehot.T @ data_arr) * inv_counts[:, None]) # (n_cls, n_genes) frac_expr = (onehot.T @ (data_arr > 0).astype(np.float64)) * inv_counts[:, None] mask = np.ascontiguousarray(frac_expr >= threshold) # (n_cls, n_genes) @@ -738,11 +743,7 @@ def _analysis( obs_score = mean_obs[c1, :][:, rec].T + mean_obs[c2, :][:, lig].T nonzero = (mean_obs[c1, :][:, rec].T > 0) & (mean_obs[c2, :][:, lig].T > 0) - valid = np.ascontiguousarray( - nonzero - & mask[c1, :][:, rec].T - & mask[c2, :][:, lig].T - ) + valid = np.ascontiguousarray(nonzero & mask[c1, :][:, rec].T & mask[c2, :][:, lig].T) res_means = np.where(nonzero, obs_score / 2.0, 0.0) n_inter = len(rec) @@ -752,9 +753,17 @@ def _analysis( seed = 0 _permutation_test( - data_arr, clustering, inv_counts, mean_obs, mask, - interactions, interaction_clusters, valid, - n_perms, seed, pval_counts, + data_arr, + clustering, + inv_counts, + mean_obs, + mask, + interactions, + interaction_clusters, + valid, + n_perms, + seed, + pval_counts, ) pvalues = pval_counts.astype(np.float64) / n_perms diff --git a/tests/conftest.py b/tests/conftest.py index 5ea80564..26bf1fa4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -264,7 +264,6 @@ def complexes(adata: AnnData) -> Sequence[tuple[str, str]]: ] - @pytest.fixture(scope="session") def ligrec_result() -> Mapping[str, pd.DataFrame]: adata = _adata.copy() diff --git a/tests/graph/test_ligrec.py b/tests/graph/test_ligrec.py index d50bbec5..2bc293cc 100644 --- a/tests/graph/test_ligrec.py +++ b/tests/graph/test_ligrec.py @@ -3,10 +3,9 @@ import sys from collections.abc import Sequence from itertools import product - from typing import TYPE_CHECKING -import numba +import numba import numpy as np import pandas as pd import pytest @@ -200,8 +199,7 @@ def test_inplace_default_key(self, adata: AnnData, interactions: Interactions_t) def test_inplace_key_added(self, adata: AnnData, interactions: Interactions_t): assert "foobar" not in adata.uns - res = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=False, key_added="foobar" ) + res = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=False, key_added="foobar") assert res is None assert isinstance(adata.uns["foobar"], dict) @@ -213,8 +211,7 @@ def test_inplace_key_added(self, adata: AnnData, interactions: Interactions_t): def test_return_no_write(self, adata: AnnData, interactions: Interactions_t): assert "foobar" not in adata.uns - r = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=True, key_added="foobar" ) + r = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=True, key_added="foobar") assert "foobar" not in adata.uns assert len(r) == 3 @@ -230,7 +227,6 @@ def test_pvals_in_correct_range(self, adata: AnnData, interactions: Interactions interactions=interactions, n_perms=5, copy=True, - corr_method=fdr_method, threshold=0, ) @@ -308,7 +304,6 @@ def test_paul15_correct_means(self, paul15: AnnData, paul15_means: pd.DataFrame) interactions=list(paul15_means.index.to_list()), corr_method=None, copy=True, - threshold=0.01, seed=0, n_perms=1, @@ -323,21 +318,15 @@ def test_reproducibility_single_thread(self, adata: AnnData, interactions: Inter old_threads = numba.get_num_threads() try: numba.set_num_threads(1) - r1 = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=True, seed=42 - ) + r1 = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=True, seed=42) finally: numba.set_num_threads(old_threads) - r2 = ligrec( - adata, _CK, interactions=interactions, n_perms=5, copy=True, seed=42 - ) + r2 = ligrec(adata, _CK, interactions=interactions, n_perms=5, copy=True, seed=42) np.testing.assert_allclose(r1["means"], r2["means"]) np.testing.assert_allclose(r1["pvalues"], r2["pvalues"]) - np.testing.assert_array_equal( - np.where(np.isnan(r1["pvalues"])), np.where(np.isnan(r2["pvalues"])) - ) + np.testing.assert_array_equal(np.where(np.isnan(r1["pvalues"])), np.where(np.isnan(r2["pvalues"]))) def test_logging(self, adata: AnnData, interactions: Interactions_t, capsys): s.logfile = sys.stderr @@ -349,7 +338,6 @@ def test_logging(self, adata: AnnData, interactions: Interactions_t, capsys): interactions=interactions, n_perms=5, copy=False, - complex_policy="all", key_added="ligrec_test", ) @@ -377,7 +365,6 @@ def test_non_uniqueness(self, adata: AnnData, interactions: Interactions_t): interactions=interactions, n_perms=1, copy=True, - seed=42, ) @@ -406,7 +393,6 @@ def test_gene_symbols(self, adata: AnnData, use_raw: bool): n_perms=5, use_raw=use_raw, copy=True, - gene_symbols="gene_ids", ) From 009cb6263162218d29d27d0f5ec1ea212e064570 Mon Sep 17 00:00:00 2001 From: "selman.ozleyen" Date: Mon, 23 Feb 2026 16:00:46 +0100 Subject: [PATCH 6/9] reduce diff --- src/squidpy/gr/_ligrec.py | 43 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index 3855873f..93c44c20 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -710,27 +710,26 @@ def _analysis( - `'means'` - array of shape `(n_interactions, n_interaction_clusters)` containing the means. - `'pvalues'` - array of shape `(n_interactions, n_interaction_clusters)` containing the p-values. """ - clustering = np.ascontiguousarray(data["clusters"].values, dtype=np.int32) - data_arr = np.ascontiguousarray( - data.drop(columns="clusters").values, dtype=np.float64 - ) - n_cells, n_genes = data_arr.shape - n_cls = int(clustering.max()) + 1 - - arange_cells = np.arange(n_cells) - onehot = np.zeros((n_cells, n_cls), dtype=np.float64) - onehot[arange_cells, clustering] = 1.0 - counts = onehot.sum(axis=0) - inv_counts = np.ascontiguousarray(1.0 / np.maximum(counts, 1)) - mean_obs = np.ascontiguousarray( - (onehot.T @ data_arr) * inv_counts[:, None] - ) # (n_cls, n_genes) - - frac_expr = (onehot.T @ (data_arr > 0).astype(np.float64)) * inv_counts[:, None] - mask = np.ascontiguousarray(frac_expr >= threshold) # (n_cls, n_genes) - - interactions = np.ascontiguousarray(interactions, dtype=np.int32) - interaction_clusters = np.ascontiguousarray(interaction_clusters, dtype=np.int32) + clustering = np.array(data["clusters"].values, dtype=np.int32) + # densify the data earlier to avoid concatenating sparse arrays + # with multiple fill values: '[0.0, nan]' (which leads to PerformanceWarning) + data = data.astype({c: np.float64 for c in data.columns if c != "clusters"}) + groups = data.groupby("clusters", observed=True) + + mean_obs = groups.mean().values # (n_clusters, n_genes) + # see https://github.com/scverse/squidpy/pull/991#issuecomment-2888506296 + # for why we need to cast to int64 here + mask = groups.apply( + lambda c: ((c > 0).astype(np.int64).sum() / len(c)) >= threshold + ).values # (n_clusters, n_genes) + + counts = groups.size().values.astype(np.float64) + inv_counts = 1.0 / np.maximum(counts, 1) + + data_arr = np.array(data[data.columns.difference(["clusters"])].values, dtype=np.float64, order="C") + + interactions = np.array(interactions, dtype=np.int32) + interaction_clusters = np.array(interaction_clusters, dtype=np.int32) rec = interactions[:, 0] lig = interactions[:, 1] c1 = interaction_clusters[:, 0] @@ -738,7 +737,7 @@ def _analysis( obs_score = mean_obs[c1, :][:, rec].T + mean_obs[c2, :][:, lig].T nonzero = (mean_obs[c1, :][:, rec].T > 0) & (mean_obs[c2, :][:, lig].T > 0) - valid = np.ascontiguousarray( + valid = ( nonzero & mask[c1, :][:, rec].T & mask[c2, :][:, lig].T From 5b8a9a64d724a301fd897792384fe55c93a18d81 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:01:29 +0000 Subject: [PATCH 7/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/squidpy/gr/_ligrec.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index da25d776..a4b0171e 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -746,11 +746,7 @@ def _analysis( obs_score = mean_obs[c1, :][:, rec].T + mean_obs[c2, :][:, lig].T nonzero = (mean_obs[c1, :][:, rec].T > 0) & (mean_obs[c2, :][:, lig].T > 0) - valid = ( - nonzero - & mask[c1, :][:, rec].T - & mask[c2, :][:, lig].T - ) + valid = nonzero & mask[c1, :][:, rec].T & mask[c2, :][:, lig].T res_means = np.where(nonzero, obs_score / 2.0, 0.0) n_inter = len(rec) From 116669db9e15836b9c8eaec93ca4e7771f4b7ed1 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Fri, 27 Feb 2026 22:53:42 +0100 Subject: [PATCH 8/9] random states per thread --- src/squidpy/gr/_ligrec.py | 67 +++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/src/squidpy/gr/_ligrec.py b/src/squidpy/gr/_ligrec.py index a4b0171e..ed7dd5d8 100644 --- a/src/squidpy/gr/_ligrec.py +++ b/src/squidpy/gr/_ligrec.py @@ -598,9 +598,8 @@ def _permutation_test_chunk( interactions: NDArrayA, interaction_clusters: NDArrayA, valid: NDArrayA, - perm_start: int, - perm_end: int, - seed: int, + n_perms: int, + chunk_seed: int, local_counts: NDArrayA, ) -> None: """Run a chunk of permutations sequentially, accumulating into local_counts.""" @@ -610,8 +609,8 @@ def _permutation_test_chunk( n_inter = interactions.shape[0] n_cpairs = interaction_clusters.shape[0] - for perm_idx in range(perm_start, perm_end): - np.random.seed(seed + perm_idx) + np.random.seed(chunk_seed) + for _perm_idx in range(n_perms): perm = clustering.copy() np.random.shuffle(perm) @@ -649,37 +648,30 @@ def _permutation_test( interaction_clusters: NDArrayA, valid: NDArrayA, n_perms: int, - seed: int, + chunk_seeds: NDArrayA, + chunk_sizes: NDArrayA, pval_counts: NDArrayA, ) -> None: """Distribute permutations across threads, each with a local accumulator.""" n_inter = interactions.shape[0] n_cpairs = interaction_clusters.shape[0] - n_threads = numba.get_num_threads() - if n_threads <= 0: - n_threads = n_perms - n_threads = min(n_threads, n_perms) - chunk = (n_perms + n_threads - 1) // n_threads + n_threads = len(chunk_seeds) thread_counts = np.zeros((n_threads, n_inter, n_cpairs), dtype=np.int64) for t in prange(n_threads): - start = t * chunk - end = min(start + chunk, n_perms) - if start < end: - _permutation_test_chunk( - data, - clustering, - inv_counts, - mean_obs, - interactions, - interaction_clusters, - valid, - start, - end, - seed, - thread_counts[t], - ) + _permutation_test_chunk( + data, + clustering, + inv_counts, + mean_obs, + interactions, + interaction_clusters, + valid, + chunk_sizes[t], + chunk_seeds[t], + thread_counts[t], + ) for t in range(n_threads): for i in range(n_inter): @@ -752,8 +744,22 @@ def _analysis( n_inter = len(rec) n_cpairs = len(c1) pval_counts = np.zeros((n_inter, n_cpairs), dtype=np.int64) - if seed is None: - seed = 0 + + n_threads = numba.get_num_threads() + if n_threads <= 0: + n_threads = 1 + n_threads = min(n_threads, n_perms) + + ss = np.random.SeedSequence(seed) + child_seeds = ss.spawn(n_threads) + chunk_seeds = np.array( + [cs.generate_state(1, dtype=np.uint32)[0] for cs in child_seeds], + dtype=np.int64, + ) + + base_chunk, remainder = divmod(n_perms, n_threads) + chunk_sizes = np.full(n_threads, base_chunk, dtype=np.int64) + chunk_sizes[:remainder] += 1 _permutation_test( data_arr, @@ -765,7 +771,8 @@ def _analysis( interaction_clusters, valid, n_perms, - seed, + chunk_seeds, + chunk_sizes, pval_counts, ) From a2b34a048771b79a374a09ba33ea8fd6833d44c5 Mon Sep 17 00:00:00 2001 From: selmanozleyen Date: Fri, 27 Feb 2026 23:18:33 +0100 Subject: [PATCH 9/9] update plots with new rng --- tests/_images/Ligrec_dendrogram_clusters.png | Bin 20981 -> 12625 bytes tests/_images/Ligrec_pvalue_threshold.png | Bin 14454 -> 8446 bytes .../Ligrec_remove_nonsig_interactions.png | Bin 10985 -> 6209 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/_images/Ligrec_dendrogram_clusters.png b/tests/_images/Ligrec_dendrogram_clusters.png index 1dfd65bcbf30b22645b09fe01ce8f4fe2535801b..4b7794b402a6749f42960a1eb5977b2ca96faf50 100644 GIT binary patch literal 12625 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 diff --git a/tests/_images/Ligrec_pvalue_threshold.png b/tests/_images/Ligrec_pvalue_threshold.png index 60d94275e1b79b46a830e9c1496332b884adf646..9e1826bc8e63f4403ea4d88fa95aa4e62462bfef 100644 GIT binary patch literal 8446 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 literal 14454 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 V?yNx0f_ngwBt>OJiiLE2{uho&kaGY4 diff --git a/tests/_images/Ligrec_remove_nonsig_interactions.png b/tests/_images/Ligrec_remove_nonsig_interactions.png index 638a28612ccc3063f4d9918b05ff2f204143caf9..179b10a3f6f2da00f665ce0289fce870bae4bef8 100644 GIT binary patch literal 6209 zcma)AWmFW(` zq>=77cfI@Gy>Hz=@5edk$3E+vwf6q@-rxRW^mR3;DA*|Q@bIX#G@*vL(+Rf<$w+X! z^i97#?vV3QGx34DJNTe%yzKE{Ha;FmcOT?)Tfhr@FYo8>ZW3TIQLqdM;N;`u;Vma3 z;`+Zg!0uj-BJF0rL%3JSJv5(szK-TTlu}lyonP46*6hgIm5ihbGZuWyt2EC=+D6SvV zRnJNbma$m7fXkga%c<6#T1@BqfJke=Lg36#p7h-^{PF1kuC(5c(#7f!`)+n znkTM?23{P|5i{`XC_^Clx*0N|&&LaGW~%L{>pgj^YibT|F86--a0f#j914pig09dy zA0)}+*`&f|km`F2%~duXB%eNgvff{6XOVEA-27E`Cx0~h&v;Qj?4xx3$*fD)EgcU{P_F>-p^gzx@+o8!&NG_hwS+S=Ncs6ET3>l2Kojt(^Lu5jn^oZnVi z{dV<$sPlxNq}v?J%a<>icCN2a7pK3uFbCaUqqgV1*NjQsUTjrNH~ZJ6{n=|iY*RQ6ULAi+@X#r9X{Gv6uUJ^kd5Zhn3$5U45t+Kk2mO%fb z9ZX$bS-C}Ho-3wyV>CZ%WAWsJ*7p9s$AM^PXJ>T7={$;if(Qbj=64*y*@+=%Hyb0l zgoQfkJPwddIbZ!JPxy70*kCH}XhG}nzNfWr^KvGj)L7M`@t6zb%di%7JZ!&IjE*}4Q$Z>;Nv6W z4!){F-%eW=nd<-1t@qg{|2^I5}Pi=B3|E>;- z?|H+`{Ftj90umNxB3d^*oOP*K{^;I9M>tRrOE~ozUKD#X%|)|PGptm4mPzzm{P1$d z9w;tOpPQR&EB*J?@Ky~eW|{K&bY-pyk1K=+WYsda{DE26lrI%gPR6Hz30luYb;=YI zH*CJ^QoP#lK;wMWW$4+mG}aN7^I~V#)rY03h{8A-{yQhS5*ZNyIu4%$_Y!(2AzEc%Hq zEWdl+ydNJM>%1i=C%4Rg=!Mmk<>ghSmYg`F*BC&|4g%F<7Ob~^mF0n9k%UxIx;cfg zk5ABwBRmuAgCC?GPg{`kIA3u}BBw=d@U_D`HMj#OO1-PAtA!jaav&1s2-&Z$wf`&# zjJ+R;{ANe@3Wabu&e|{bo>W(C^a_sBBkv3?sIxRu?h!+|`cytRMIqQjdodmWrizDQ zS)N-{6%~f^C+o@RGu=1J^zk=UH_^@*hjI3VtJpHqOoEGu1~1rA;haS#4w$+21F0!j zhS%hi(%~}Rni-|#4JGR2*u9Y#p8w&!39`^H)m?$ayGQmk4foCm!Qc`7Do6WDp3UpM z@9SK2KrAjhnGMuuzqv%!*URLR7eucn=_%3C$+09ydj`(E(z|Dq>ZsS&_ZO+zpnv2p zO?)qMU)vX3{cR+Zr7IYqp%Ve#PUv6tlEsDzs z@0TKeRRLy|C@&Me=4E|1pe04JKwOZ|TB{B4W+*q|k!l5zitTjyquVQZw4sx02U?0! zpOKT(!XQ0-w8l~IQ$azDH8jh5A}XiT5a3`~1+)r4g@6awZuL)BhgvRWj|>b9csN)m zhdHh)D{o0c#Fk{>jEQlM$~;3nBqFx7JYi#m+H@vv{+((^$_Rb<;IJQ~TH&(|4#kn& z1SN60>IvfNT+Sn3B@e-e5%XjbRIiZH;T#HNk*47sn`aUwu)9TMqAsQrH4CuT8$8#K zWw=i9FPz87i*I`2Dp=HVM^v0slcb?z&M#xDK%>rMKEeTXHs#yC;(AGk+of=trcJ;h zROub{p4$73$ojnH;HD==iQX3gaa}jHxJq=V+E+CIWU<-*G`6=PnJZBgweYfNDZZC5 zp8cUNbliU5xI206#jMg<-2-*0EvQnKNi&g6H$X>Q??=NnC`v9XA!8*R9egTtl_5{V z?Er(Wea^)1D%NgqZ% z2YF$tRrmG)eiIe5pjoRXcK-80LYr5_PrKy1bSM}#lexY+PihC`617YJl@EUlUsxlr zRfrbO9%~MQ2VC{2KuKO%d#*isy|vIIT=DzM7hfnT(JD#)oGD58P)IGEBa1C+{fr@C zsMsM>Ejd8XYP+A6W=L)$@u6W#+dO^ke2AjpS|FrZVYQuRh>u0xL0swR=S#L zotyA$NZQYSu8C@)?*UKB&4I5U_YR8BBnc#&4i+=eQ#9*FHQciiRP1R7)g<3*y%N+G zE;4{yZ_30rHhHow2|-8F*5V&7&;ost5D9|VQNff0X^W2pF;re_qK|0qlkT>$cIozF z#5v;yv{Y2g*{`Za2m|j9Lq3TSSDS-rOrjREqa5*j(^0hHm)LZ4-boN!9atfzKlA zHf&T-m-nDhH`BDQyMukAH~O>KahkB;s8tiXs)JrbREwwJoNsU*wJ1E+Du&N15h(VY zhe2e_GSN$?#BL$r5calX`0n7}<4I$y%yGgJ)_4Tn`T4m?wOx!T)~-Yv?B5b0H^srk z%g?WlZ7&5ny)Nlr_9kFT&g%Y<0m3pi4(W zYZR8+g_#!4+p+z*zC4W+mM$RsD)h6rH$(uN4enlIS_ItRqve8KaLM1{c*g%_@S01a zhJPvPI_Y3OS5L8))hRIzeuQiB>UD1OLP6NOUgL$Z`lGSOuZlJBW1x8k9G%#@lXpbB z*RHac*lo}p+Y~i#d3Mm^yGWUdKjp*QVSTH6&hstC zq(?e*FfDwz%q@;A@_2Af&QY56H8=98>Aw)wQF)v&!``XUZ9SQ*!?(WOar*Q&M>W%r z+L!H>bj4Y#jV653z0ZZs4{R*ba+a4^H}BCwm4YUG6$5Xx-w!3_YHJTCC4N}emU0l# zGaZR8Fmu0p;qj0}(*2Fu%B=hZ>)6%vqxfddsjj)$5E2_o=vz8E{NvOP% zIfhhWOqi4lDpxg19lalF$?U^!;q&6>x?+8J#lZYz)g*%90>@CrmJri#N%KlRzL>?v zMPuAsEok@pA5<{nce@r)XCW5MYB}2WN^5_Arz7tjP<&-SAT4k7G|35DXa2cK_->qF zQ8j1;u|Th?Ysg&fzNjQd%ecN@7)8w~Bs3BdK~I!AD)kC#UW-gOj^|c1jXv37%QeUs z^w}Mw)JR~IDRUg4ln>tp;VVua5?vOJ-!v9oOj_*1IAd(t8&X68S-2P|t1%jVqjX!Q zE=MdV`q#FfK9;tftM8J@um}OdN#yN8&7M59CLm_$>dxuwk{M+Caz_s>14F{>H&vtM zRSTaJ3+(2fV}ve5%s#FBLPtxBZf`r3kd)&3t)f%DGGT^gvmg00`nuwB5GN1_T=5`D z>f@s}-bd_~Co~3SwmX08$Bhyx??tS3zbZ&bz!T!)lFYt2-`~U34d*Jz%08v0re0GO ze#0u!og09o`qg||c?wEOmS7<8Got1BIRA{3WUN<^P`b7Gy`|cj^z?KU+Ck1z36Mnt zqUp`)?tC26gC{b{hjrD};s>{SkFK%$S;@%=G0Y$B{@QIS&15{l6&C3o6~e0@5TKxR zeFWFZR+@i)&WV}}#nEhv<2#G3Cri~`SOgJq&q@9H+DEk&+AbZKX98R~R1!5v^hiITts7#IH@QdP+Pg)FgGZtb50(3x% z6`jL~v}up+nUwHgZ#~<~RMqIuD7Ti9YEWDUCfWIhCCpR|W%rcPQOt3qvHB^};P!OY-~N;bib6wwWAChP zF$aP5W)t}qE1eRq^n%La1&E+NgL6#--FU%mu=H8L$G~kLa_KcLl}5QLC$81GpH>(L zt2PUBA19UzNni6{l4#O+nt$=vc;!%}f_s(8!O7YE-oakA%nj2tnQP05k{!OjB$D_0 zpAQ21H#5Tk-mSqTivD#ydUxQN+62=TW!f0#cM)++N>W)5Nm%vG5fr6H>IODIm5$fC zTW^`hn;Jzz4g%h|%-Rfe6QYs(8cs8XN|piFC*IOo#`>JyhhJ}ybM<1gU!Qwh9&_}OECr_l zr-m?Vvo|^Ok?&ZOXhersQI|JoAC^LDEKq^;!wQG5$8Ve#*6JE%@<#<%8Vxpw@bidX zP81vbAW7^jYzU=CQYe1MUjcaLoHu##b{_6*&9wNl3M5pJP*|SdeA|1oU#M4ZT7HqA z&|5)fC!QW*qtMR2j_28i{te1cc&yc>nnG}Y_;sH)FRX-0ljGOreg};jhw-H!@(n4( zR;=kh>=#RCN6RB>nej99W|FV@aSsP9sH*d4hAezr^84s=n;_u|&HkMf6fRBljo<^N zXuh+7@{#q&=0l+YAgg_5@jCg)fO7B`vpQWTm2-k3i;yy@5)m5p>w?xqk;{LCW?}hv zn&{QzIWIk$0Lf(yN^T4{r^q7)SY1QavmR=(q_mYX{#2P+=MkQ%ahBp)bP%RHaAUsW z0g2MwozY|7N}w#^4%k=cFE(DXZJXbcV{($R09k5!A|-e?KQ@CVa;#2d3I6+BI`f0| z@vZc;t~>-Nz40z*GZOfa_1N!qpGhhL^p|DKlIZ z2N3e0s-Gv0<5sspS4d^~`lBPCV$NhPj{*uAf`X!=-(8JX;pZlzv>Zf4?B5*-)yaPW z2(bV{khdz=BtIJe>u z&&%T_3jN!!r^0L(dasuP*!$W#pY@))gfnqUZfzAud|00TfPTWbI>V-BSF~g@V9Xkt z+#Vv>Og&1%YT(DJUXw5X$*OPViVjDj5}eIe1fS}6f~3C3VQO8??&qUXf-;#jHL@&% zGz2~qyCj4T*&w2HXnM!^QK-4vJRmvQMjPBzlG<3Rx>CPohTnBTlfaIxrFpe9bRnDF zke9`@F|iQXJh$PxA{VFvxZe;dk6tlqTDC8j(+G+()usMaw{Bd(ZT0o(&g&AAMP&?M zF~_@#bXB1tr!#cM53bCa!x3z(S){Qt=?1K zl%TJ&InDSMN`J7vIAU+?oV0Gp+<7{{WNdCgSDi$E2V4~E7n#E!5@z(Ps9k_c?T=sI zQP$DZZ)F1>jylcVPOm3uFOejp(jgSW)S@CotQ&PNTU=!bE$B7ga0y4WD!kWi)Onyi zf44@?OaFN#L@nD@(I2IsTEj&ed~F?Z`Y{G-D}hHET-Gyz+H5Q{XDg`R<->gAz>jjjCv z&j`|GckX*wh@_y6MYgb~q)pyKnMsy%@%!R(HmyO|5xgQrvhQo$zOn-wJ*6f~??|z#odx=0c6|7s zU22E}pEBvW%ZYh^hi@8&!$@7}(=2~~@rR&11DF$)+00cj4y7Ek(q{kEz5l%%{DLg|tiiJ^v&?hfhh?oR3Mz3kbuyMOMU z!!VvXkLTR`zWe>^3znA^LqjG&hJ%AclMokH1n)uMmG}}7{3e4%dw@4CClPfgC0i3G zSA7R#I2nB>J1bi!D{})<7h?xUb6XoWMs`M4dQvkdCp$-OCMN6uzJSrz!IX*4e^nja z&5G^v7GAxl6)aeX*oE;P#Nmm-T!NV7Kk-;Y`qxq}4kNoadS%Fe zw-4Xmdma4nyx{iScPt;|_be{In8hRuXbA|WisC;+Omt&m!4GHJ8Ks%hRnPCi{PbDLmMJ zGT)ytVPYS!JbU(x)9Z@)Yn{`|@?ZbB*~AlqAlV!``~Jnn)FFY#2F1Bka>_PpBz$~) zxb@9VR;y`dFK_SbvoSgN8ix(!MvwE>si_1K-@B3A{$wskJQjl&{QUg=i7ftCS8f-F zOGEXCO?|VKmakAyGPSC0oOVV7mwX@JeM{x@WOezc{_Od4sR}Aa#)wkGu4l}So6;T^ z|9Y;D*Uq~-BT3|nbsD!9>Ny|~2wa8jQtIWBFSN>f&f)sRIIa8W_H3sOd}1r#teS5O zRx6#z>^Dv9VtfCZP>+1tOd zo~?MZQ16OM8z)(yUMl!+wo>XPSv#(-7a7vedB8AkQHde=GDX&u^s($%Ix?oDgn4(WSkA+Pdt_vUUbEsQ*pb)7yyA+Am@_jo zHMOJya3j}a-a&k6HP1b2tOibuVNJs?*1@g3UaGYFR`lhB`*)mB-$H)5n zvy~LHuD2J~-i=@-6C}DNdhmQ6XL=KVR6Op9iHWJ>B$JbqySuwdKYfaK*`I|UP8a&Y z>iZ)x5sy+f`DbJ#x>}Jo84FAF&REXr$`2A&^HFkSWMtZ#u_pO$=mvS7a&L|OntG$h zM{qMx;6LG?Ba#Mu&Yvi=UlRqp83;#! z8F`d2NcjB-lq#TQVj|A>@pcJLyTPrr-C1Cf-__N%Gn`Ni?EZ*Fr8rCxeAn%<9Q05e zhWDJD)1M!V*C1VZjh>gAp!&S-j=S!T+R(4AuScqFbulq9U!tNKjpr&{-Q1K}&wa{d zg#%CQ>gsBFWyLQu^TYkk`3F+cZv#mjY?hPsi|$+R?(XgqKUr(m&TT9&f9{KC*qJG( zA|oTC#zMB3Dhd~VpKiO$xNB?(r>)R`Cmi_Vt{)1>(nph<9zqx8Tfl2Ck-xy_cRL{jkF;v*ad~9Io5S+vu(!(r` z*I|T58D_wW`d0KmJ5(ZSTY!RekbWyQWgD|TR;+&F|KYMmu zPDN#CYI-?-dN5xrBrGf(sMgVVF>BcXUN6bE=WE%(;dh_RZjNbS=Iey8nI4^glsoI>z#lu6Wr8Vb}Vrpn81RC47 zjLJfA60t`;ua5M+<&0O~Foo$m9n3@8exN_M`!`*h!}x~2b8b$n`Go%c`)9bg2@ZQ< z;o%)KGaAh=M%zE=;I(a!=e-RK3_RN&@&9B!`^$3ql?dJBM*=jkH|rZ4D;pasHYwR% z;ya0xaf!^mC}W0+Zl%mm*zrG4W~hVWU~NrEQc^N-C||dkUo3)%raYC(m{w^C8oXtI zM?*uSUF!gA-KsPjS4W}fx6z9YIsH)4(R?HWAtE9IyJ*iiQ}P}U)NXrc=jzUmd4+c72pnlbf(cF#z_CukbQiX?)<%YofErv)FDk!`nN^cOobLsQDK^aD|vy6UOJRi8urk)~BjP zf3%z=C1E#{ucR$JR1fuiaAnn}GN{2$5&!B*Sa*IZoPKvYAI`)L8di}{+qaCAK<+a$ zNy5X<7HEE`55BmBggbxgP*hFQi$Ew9Hv_iV9s#QDOwgrP#it`5%JrNh%e%t>m>5|? z_qJv=pHT9RerE15@9(=DgXI>#iNoa^viD)7kGFBT<3`%nmeDauZIAExTKWyiJRqL# z68%4~g4$?u@-izr1Wg8QUm;zy8sN|D&hOfsKrwfAC-UK^OZ1<=<8fi9zB{Ntbh}(| z^~+dz3(&78S0VFfLD?|o#^m&vdX(FHjhS%M%?+&ji`qu3N3}L(WvZMh zCAtyi!C(|{hMi$7(-97!!Yj+92Nlm)Eb zguK%x2p4_ia;J*0iVKOHb7>vyC4i#~jA?ddoW-^FwL&Eb4{|hrQ&D!DX1FgFt?Qq1 zqbQ}{HiNW+=&X9VHT2yHfR8B3>v!Dk05^VygshIj0J(ZXa{!pEM|RKHZRQhmb8}fe zL(4D{RV4&RRMV+slimJki;KSm#g4F?1NAWg&0D+CLq#_=A>l`HF%$UH)necKJ+?}b zCWj44fDC8wYWv~9W$!e=v4uCWHNTKurH+pW59 zS60+>(}(-hCn_Y?8LjDP_ixE&;TfwejtjlbJwo#$3MD-sX07rdMpqN6%Wi1pd|6$R zHigsfcwT!wZ1ro-4>ZEgyX(_8g3E`8k$}1nCUbFea;C{sbNW8;0{lq_C_Ftg!(zM0 zdruBeN=q98Xpw*;I_{e{3YC3Gshfg6Ga2pB1S#H+ALDd=t^8aQOY^~v z#R3-#y?Q20-jSQB6ib`G-T0O3w;cbTL2Eqe;-X1zOT6y!D5Xinb&5h}VtupHKC3JY zuqYs(u$c~)xE|_QOwb}{xUYqCTwToB+B-XUbaVs)%6mLx3SHUX4;M^t>FWzU-T5m}de zg=hq3AiJspKfZ27LeijF*qkeZO+*TgKnXehAxn9_H=k;SkVb zFVWCC1_lO5{}t26yk&(m8}0wQ>~?&)>hN|5in)2Xzb^xLWv$)HGr$V<05AYL8G?X< z!we)IoX_LEtId4PUw~?oh0}@t`3h?p(q3 zHf9Xp8>=d(yLJ6R7As{wH50DI5i?V@!g;=Ly}dhDU{O3NT$|@2AS&v?h?4!-zJX$Y zV~D1`%et4O&+d?~oAEl9WbImh-#gQqfiAv)a3I_=xLrpsD>Vhpny0q0ObW};U%BA}_G1L<eHnrTO>*A5=lJx`a`%}=-0``)tUeJ45mw?e{_^hbCp|GO8EtL)~Z0S61A8M#He z%{I%+y?uQjxVe9$;4%&a!5|tz)Kk6W(+7y}OJwBt0s>H`rYpb!alyq!k!#GNCi?J? zs60Za+~?E$?a<#Qo4FO5ok)lJhXZE(jY|;K^WFsQCf;?kldF#pt^^v~EX5N>1?%y- z&IIuyNcJax5rWpw+6L+@=8rmt&Vbg)>E)N%jEonM>71^~Y|mV`M^Xb^wxpzll#%f@ zOfk>ZXaK_Y<4L5G#`cKI$iP5#BhpQUx$_GhE@pqu)OXsu7-wfg`b-mhJawV-VyW(L zx)z7|1v6!4qQGiln0RxLWR6LiKa(oD?i5{?!=lTe-dTo07zVVsYt`qmk z>1ip@shX)NDFXlyODij3U+Y~q{*u zL!4xASQt46N3}2nAY}912?~kV5qzd-xB=kr>KYn5eFOddS2ugbPgJ(3sK{x1*bgv_ z-M+$t0`&$rmj9Baz{wabH=ZZW1jTXRX#72pn^AwGm#^tdtn&GyJlxkcIi``9hNFpRID4eH`_^ z!fAI~fkNw~6(fDIZ0@81`({!R!D^3pv{{E59-&7^b}=zAZlLclb8v7_-|pl`vE5H!Kh3UHT`g<9tuEG#Tx#C!yWs(pzp zhEGd{2>3Sn4Tj<}jpoRa6zMcd0)t1=y4q;1Gko=!Mby}!pR9sslUC{V1A4IVcYkNJ zgRKq4v=QcXA>4-pl`=HnaT@1;4;oF456=Mz*Y#><<(C66LUkr?xkXzlkpGJ9Y=?k= zaD3cF@*$uFJkw0E$P9VvZy9{cz$}n_ArIwp3EHL9FMA2nMx!< zk#ydQ=6=sttqQX*W@gm#6?xFu*x055&ZA3-`T2P`N%|2tB2&{XMWo+w@$vEOR?`@@ z+B&;>bw2ko#U!X0D&l5lxu9z^*lWWWFmOj-hcsSJhQ94-4%T*#A2~%q78LQA<&92l z63%@5QpWUSQA>3dttH7D#bOB4;cP8qfsOU)}9Ey`hcaBye`dZOL)u#pO& ztWw6EKRQ3gw}-JjB`O7V^;}!ASV|exUigs=5tQ@^i$K|&w*$XF(kZ#TvtjB8W!|#F)2tvc3+64X|K;ZwZ&g*p z(o!_~^qhj!&xH}2Uv_V|ogz={#mQC2|J?I!Zt`RQ$$xJcMs<(Zd0>|!+XYS!r_UWH zI7?wfT%(^4z)_Kgum(OydJQD?_ENI|ood1GB`;9QK!6eUBLXGX2TpuxRTU){7ldQU zYfO(NM)*A$GxIQMQ0H`i)-2lETw-&yW@5|duF@KfYRcN>PxWh$!LnwMS^Afb5Q-U% zzV1Ptpui4{0?RuzGUT4#7%tS7^lvEiMpd^Cg${DMPi2yym#V$2fpYB zU~Wac<2hL${#>Y9?s;hwK_UaRmo@b-L8> z=78%Hn__x}wg$~qM1Ne>}Di%L#l;sz2^2)>L?Nnt1}FMo{cCt+YJ?q3sUU|^66*fKUXE%&+i zcsNOD-i~zl^nCKSjN~}e!KK%Gx{ps5AFz1WznYNdOCY5cK~{W7oClJL<8$~LP!ywj zzDDzIev6T`j+Wd@pP(R2rc=-rRBX6Val{Xh)m5dZOCTI{TR))`@u&!LPOrUEZ(K3f z>C2B8Pkd{+)Z`5~{A(i47Eoi~GANuuu?yym;dg7QtIL<0$oOyJVd2N*-hzStBmyhG~^5%9%X2W&b^quc~>#l536 zrGsfY%}ineP&EjP=o;_V)H(Cp}a!+I+*!&*i^Ug1dSB4ysfI zIysZ7pmip)?1c)ypl!V#MEE;v5;E_;$4yWNP?K|*?zfdiqVE3cCj4MQ@_D-~Dv1mK zSI>q`V$x+MsV|2)I(s6-S*);DQs=;A-NCiLAB`g`TK%L}&}Y>~k-rkIZM5FcU!f;T=x}4k_I> zHfk9xCU>3YhX&HKM^UBN$`$u=5A>FkTiLTPx#D|&Q}jyzZBh9t`*Ko;zpXPDWqxR= zLoahe)LaWlP{=%RB0SOmm=>O5bz5~vck9~vai z#IAGPXExJ`iD$~6bZT1$`jM>Mt<{}5kiK0t*j+3p1xZ_oj{UwLLB{4Nk1X38Bwuva z5R(zI5{S;hogFo=9|2eqCbeO?(Yvgk>4_76xz5DKF^DzL7u~? zwj8LF)yMf}?C^LGMmzQ@F=1t;# z_Qv&Ker3UR$@uzY1KckvCB>S34a%Z;>Nw+Y{{Gz_&+*buYh>7ghFf3GI=k+S`2 z8DFc)9U!%!kPu*VX`t|*EGHn;9oIV1;D8Qn1E7=(%tkho{xl%h+q=5j)t7-$ z4WM0t8Viu)tbqPCXH*jY>QC`i8A=BsySQ}v=pC?JXYhK6mwXq132{2Wjo3f2d|;{tC} z%!9O1^JCjtg3}+7XnDe|NCTmZ(aANqY7~%*gN^pn=B)d5Ge<6{yQ}0^LzcsU_;2`)CAX(Qp_~&@cdT@_d zT>_{&&7K|}WnR|~2aAm;ddk@~5d{SqGxH$*IB%k+epE!OWm!o}BLxg!R$d+h)X4bO zH%U1;Q?OpExvKDuz62$8b@c;0K{(4mOlo9gpX9I~OQC&qgme%J$vn(B*}xaCs*nl& z9RkO{KeLYi%ThR;Ho2isQLLr!9mzDu>v2X6N}=KYVh)4|s)d>&Iy%IDetxKgpTZ2g zBBFo)?&UdbXlwGmeL`Ij5^Mlb2VC^AI%i9lgL#P`uisq();40s4rFTrP<6ke+#4cR zt-?Uu*l^rQ_;0>879=!B8wt9YZ{GMpp}LSM(vcmfCsb3{(#jk$0~Y%lKsI1Atgf!& zv6-L(Bb9X;VFxt%i-Uze;0YXGUYfQByz;2Nj?Hunm;G^hQHf*D-fZEW1% z++`?u`;z-eHLZc49Z*!DAt8O?EDKA`hBzxV?BXA^Ir$JZP7ZHTYS|@cZEvs~JB(S(IprIGD~*zGUT)3b9$*)8E9cyREj%-)v>!OAEcnA?ucfQ@X-H%E);3?PZYlk*; zCA_VQiM=;7IXR7&=STMpTZ1Ve6nTQdI_JGkV9{}UUeKTKPGpLN;sC2ASS4R|1SkNY z+W!L8bv5Ms;IGZtH8K)C#D9y*+~o4{WYrpTZ4CcBkA%+yw{gX4qUa6O`+S>Yz zgapbOXKaFn&^TGm;lzyXJJid5j>m*dK}?~PEv>)a9X(%T?+=cPK;?H3^gTIy0*}{n zF8`*}1#IXmifeP&6!QWy|3(A67JL!;LhP9cmq2*nSfe=9bmeHJt-ZbdPQP}2-H4Tq zjZVf?gO#Gl2?)LU2KQLdcQvc5G*$yZ*mQh4BoGUMNF;r-wsUbY0VyXiqek-8idhL2 zmlkoF;R|2Qcm|t8A3o1T6wTK`T7&)7j?7lXJ&XTnWUIfsTO)Z)lTG2)Zt}v1DN2AC z3z(m+zz(XYU{|Kau6NmQ9T*4)n~sT(9}IFV;CbqSbrpQA#|aeFCq)`A&|uT7q$T*J z+vpJoE;BGRR1#bQN0AGp^wH7L#=8L-ox@k!eW+}7%|_t5avk>ax861-<^afaN{NbM`~#a3Nq#Z46rZE?k7g~ zy=Q<GFn<_;=>Iv|XJTG0z#k-Tv8~y~CJNTm4|H_Aeumw?rfz2lY-N>w*SQH+_hrl783nQUs*Q*j^B*sO# zWusQ>=hu$rTi5h=w$c$-$$kJd6 z2|{JV?LmD%Q~)|xjrRBrIOLm2woUpwqp+Hq8n{lO+t!$abnNec!?j2g_#M(4#O~M*_ydx|sM8?skBD}re+Z5_$O58dl0LDx z9qDnq{QKG5EC7tK2{i*-+o3045cn1N{9c^kvyZV)3 z4+XsnkElF;jwS0avIXrs$?l{o7`4EJ4~kQn$zM?-QpRh zRm^VHRQ*|5fI(*t|1k?ey$`u08cUC1!hj+A4Iu-(OWZbsZkX-~b~^!~mGOBLEqzNr zYX1PnCUOAeSqg+G1%g`m^z))n@#aW(n1;e^;B!7)%E^itye8~3H3E0wXKH70EN&yo zcMdQ_wm?M3Kt!oPWV%3P%fJwVkY^U%C?(Nbh9CO6MB{%HN(U863%<*15&iR5mA328 zFZn`?rTEtNKEJ1F3z#Gc1sEIxy&zswM7liSC-$=>Eoq6TZe!i4#h zkd8Yz(c)*Mopj<6)Z&C|`)d|`p-@sHRg0xki)K)Yrl2ElY{$zyTEP>NvZ@I9Bs$uy zmZy&=b$7wp@oFLD)KU#5Z9KlQxp^^e`8@-nKlbtbSZVILNasbxClz5?rhNFJO}T_P zN|7|hxOwhINwl_+5rs#eX8PVW++2D{Z&4qfBn%TYYUBw8h_x@y3{{>YQbKS7?%47e zm=Ey@6A(8tBG;<2`jRuoVlhswS!p3+VDMflp8hYeY{8V$Zw}i*!0&-m0P<*dy~zTN z8@Q!_fEQqdrvm`v&TJ(e;IYZP?lFLJ6OKcoqFPovLQAWwsi>%afYFYV`^zQA%Y(1L z0uuru(b-}3Q`-X*N8NpWZ8I|v`?bzDZ{PZZGv>TIKGNmfnt3M2$jJD_lnZp5Q!6Se zb`M!HGC;<-^f(AkUY}QBgWZbY=;&x~K|U|*#)RuiE5iHt@1LyK=_+fj8Qfz$!cSJq z`Nd5J7yo9grc21422Xhb?}a;&lL>}3|?JNPpdaQ+*m%nioVv1YHAW7y(U@) zY>wAW{#VFt&zT%>kie`J$SKwCBQb$3e5M~It6?7lJU!`T&S>zYREu?__!h6P6a z&dpP}owi#5jP(2<@#zL4=Lkp*pse~+cyOsb?)V@NdHQWZ)6>(#P2QeB9I4!w$AH%V z$!hwW@OyobWy5X|7B`E!24qnLrpN-~vVZ$@@vX-yZR}q^>SK*L kP5l4Ip8jV#YU2^!DTiiDX4nY}jGe+sh{y^T3F>|S4{m-Uxc~qF