Skip to content

Commit 87ae1b0

Browse files
author
niklasmueboe
committed
draft
0 parents  commit 87ae1b0

File tree

8 files changed

+396
-0
lines changed

8 files changed

+396
-0
lines changed

.pre-commit-config.yaml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
repos:
2+
- repo: https://github.com/pre-commit/pre-commit-hooks
3+
rev: v4.6.0
4+
hooks:
5+
- id: check-added-large-files
6+
- id: check-case-conflict
7+
- id: check-toml
8+
- id: check-yaml
9+
- id: detect-private-key
10+
- id: end-of-file-fixer
11+
- id: mixed-line-ending
12+
- id: trailing-whitespace
13+
- id: no-commit-to-branch
14+
args: [--branch=main]
15+
- repo: https://github.com/astral-sh/ruff-pre-commit
16+
rev: v0.3.5
17+
hooks:
18+
- id: ruff
19+
args: [--fix]
20+
- repo: https://github.com/PyCQA/isort
21+
rev: 5.13.2
22+
hooks:
23+
- id: isort
24+
- repo: https://github.com/psf/black
25+
rev: 24.3.0
26+
hooks:
27+
- id: black
28+
- repo: https://github.com/pre-commit/mirrors-mypy
29+
rev: v1.9.0
30+
hooks:
31+
- id: mypy
32+
- repo: https://github.com/codespell-project/codespell
33+
rev: v2.2.6
34+
hooks:
35+
- id: codespell
36+
additional_dependencies:
37+
- tomli

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Niklas Müller-Bötticher, Naveed Ishaque, Roland Eils, Berlin Institute of Health @ Charité
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# SpatialLeiden

pyproject.toml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
[build-system]
2+
requires = ["setuptools>=61.0.0", "setuptools_scm[toml]>=6.2"]
3+
build-backend = "setuptools.build_meta"
4+
5+
6+
[project]
7+
name = "spatialleiden"
8+
description = "Implementation of multiplex Leiden for analysis of spatial tnanscriptomics data."
9+
readme = { file = "README.md", content-type = "text/markdown" }
10+
license = { file = "LICENSE" }
11+
requires-python = ">=3.10"
12+
dynamic = ["version"]
13+
14+
authors = [
15+
{ name = "Niklas Müller-Bötticher", email = "[email protected]" },
16+
]
17+
dependencies = ["igraph", "leidenalg>=0.10", "numpy>=1.21", "scanpy", "scipy>=1.9"]
18+
classifiers = [
19+
"Intended Audience :: Science/Research",
20+
"License :: OSI Approved :: MIT License",
21+
"Programming Language :: Python",
22+
"Programming Language :: Python :: 3",
23+
"Programming Language :: Python :: 3 :: Only",
24+
"Topic :: Scientific/Engineering",
25+
"Typing :: Typed",
26+
]
27+
28+
[project.optional-dependencies]
29+
docs = ["sphinx", "sphinx-copybutton", "sphinx-rtd-theme"]
30+
dev = ["spatialleiden[docs]", "pre-commit"]
31+
32+
[project.urls]
33+
homepage = "https://github.com/HiDiHlabs/SpatialLeiden"
34+
documentation = "https://spatialleiden.readthedocs.io"
35+
repository = "https://github.com/HiDiHlabs/SpatialLeiden"
36+
37+
38+
[tool]
39+
40+
[tool.setuptools.packages.find]
41+
include = ["spatialleiden"]
42+
43+
[tool.setuptools_scm]
44+
45+
46+
[tool.isort]
47+
profile = "black"
48+
49+
[tool.black]
50+
target-version = ["py310", "py311", "py312"]
51+
52+
[tool.ruff]
53+
target-version = "py310"
54+
55+
[tool.mypy]
56+
ignore_missing_imports = true
57+
warn_no_return = false
58+
packages = "spatialleiden"
59+
60+
[tool.codespell]
61+
ignore-words-list = "coo"

spatialleiden/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from importlib.metadata import PackageNotFoundError, version
2+
3+
from ._multiplex_leiden import leiden_multiplex, spatialleiden
4+
from ._resolution_search import (
5+
search_resolution,
6+
search_resolution_latent,
7+
search_resolution_spatial,
8+
)
9+
from ._utils import distance2connectivity
10+
11+
try:
12+
__version__ = version("spatialleiden")
13+
except PackageNotFoundError:
14+
__version__ = "unknown version"
15+
16+
del PackageNotFoundError, version
17+
18+
19+
__all__ = [
20+
"leiden_multiplex",
21+
"spatialleiden",
22+
"search_resolution",
23+
"search_resolution_latent",
24+
"search_resolution_spatial",
25+
"distance2connectivity",
26+
]

spatialleiden/_multiplex_leiden.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from typing import TypeAlias
2+
3+
import leidenalg as la
4+
import numpy as np
5+
from anndata import AnnData
6+
from igraph import Graph
7+
from numpy.typing import NDArray
8+
from scipy.sparse import find, sparray, spmatrix
9+
10+
_GraphArray: TypeAlias = sparray | spmatrix | np.ndarray
11+
12+
13+
def _build_igraph(adjacency: _GraphArray, *, directed: bool = True) -> Graph:
14+
# adapted from scanpy https://github.com/scverse/scanpy
15+
sources, targets, weights = find(adjacency)
16+
g = Graph(directed=directed)
17+
g.add_vertices(adjacency.shape[0])
18+
g.add_edges(list(zip(sources, targets)))
19+
g.es["weight"] = weights
20+
if g.vcount() != adjacency.shape[0]:
21+
raise RuntimeError(
22+
f"The constructed graph has only {g.vcount()} nodes. "
23+
"Your adjacency matrix contained redundant nodes."
24+
)
25+
return g
26+
27+
28+
def leiden_multiplex(
29+
latent_neighbors: _GraphArray,
30+
spatial_neighbors: _GraphArray,
31+
*,
32+
directed: tuple[bool, bool] = (True, True),
33+
use_weights: bool = True,
34+
n_iterations: int = -1,
35+
partition_type=la.RBConfigurationVertexPartition,
36+
layer_weights: tuple[int, int] = (1, 1),
37+
latent_partition_kwargs: dict | None = None,
38+
spatial_partition_kwargs: dict | None = None,
39+
seed: int = 42,
40+
) -> NDArray[np.integer]:
41+
42+
adjacency_latent = _build_igraph(latent_neighbors, directed=directed[0])
43+
adjacency_spatial = _build_igraph(spatial_neighbors, directed=directed[1])
44+
45+
# parameterise the partitions
46+
if spatial_partition_kwargs is None:
47+
spatial_partition_kwargs = dict()
48+
if latent_partition_kwargs is None:
49+
latent_partition_kwargs = dict()
50+
51+
if use_weights:
52+
spatial_partition_kwargs["weights"] = "weight"
53+
latent_partition_kwargs["weights"] = "weight"
54+
55+
latent_part = partition_type(adjacency_latent, **latent_partition_kwargs)
56+
spatial_part = partition_type(adjacency_spatial, **spatial_partition_kwargs)
57+
58+
optimiser = la.Optimiser()
59+
optimiser.set_rng_seed(seed)
60+
61+
_ = optimiser.optimise_partition_multiplex(
62+
[latent_part, spatial_part],
63+
layer_weights=list(layer_weights),
64+
n_iterations=n_iterations,
65+
)
66+
67+
return np.array(latent_part.membership)
68+
69+
70+
def spatialleiden(
71+
adata: AnnData,
72+
*,
73+
resolution: tuple[int, int] = (1, 1),
74+
latent_neighbors: _GraphArray | None = None,
75+
spatial_neighbors: _GraphArray | None = None,
76+
key_added: str = "spatialleiden",
77+
directed: tuple[bool, bool] = (True, True),
78+
use_weights: bool = True,
79+
n_iterations: int = -1,
80+
partition_type=la.RBConfigurationVertexPartition,
81+
layer_weights: tuple[int, int] = (1, 1),
82+
latent_distance_key: str = "connectivities",
83+
spatial_distance_key: str = "spatial_connectivities",
84+
latent_partition_kwargs: dict | None = None,
85+
spatial_partition_kwargs: dict | None = None,
86+
seed: int = 42,
87+
):
88+
89+
if latent_neighbors is None:
90+
latent_distances = adata.obsp[latent_distance_key]
91+
if spatial_neighbors is None:
92+
spatial_distances = adata.obsp[spatial_distance_key]
93+
94+
if latent_partition_kwargs is None:
95+
latent_partition_kwargs = dict()
96+
if spatial_partition_kwargs is None:
97+
spatial_partition_kwargs = dict()
98+
99+
spatial_partition_kwargs["resolution"], latent_partition_kwargs["resolution"] = (
100+
resolution
101+
)
102+
103+
cluster = leiden_multiplex(
104+
latent_distances,
105+
spatial_distances,
106+
directed=directed,
107+
use_weights=use_weights,
108+
n_iterations=n_iterations,
109+
partition_type=partition_type,
110+
layer_weights=layer_weights,
111+
spatial_partition_kwargs=spatial_partition_kwargs,
112+
latent_partition_kwargs=latent_partition_kwargs,
113+
seed=seed,
114+
)
115+
116+
adata.obs[key_added] = cluster
117+
adata.obs[key_added] = adata.obs[key_added].astype("category")
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from collections.abc import Callable
2+
3+
import scanpy as sc
4+
from anndata import AnnData
5+
6+
from ._multiplex_leiden import spatialleiden
7+
8+
9+
def _search_resolution(
10+
fn: Callable[[float], int],
11+
ncluster: int,
12+
start: float = 1,
13+
step: float = 0.1,
14+
n_iterations: int = 15,
15+
) -> float:
16+
# adapted from SpaGCN.search_res (https://github.com/jianhuupenn/SpaGCN)
17+
res = start
18+
old_ncluster = fn(res)
19+
iter = 1
20+
while old_ncluster != ncluster:
21+
old_sign = 1 if (old_ncluster < ncluster) else -1
22+
new_ncluster = fn(res + step * old_sign)
23+
if new_ncluster == ncluster:
24+
res = res + step * old_sign
25+
# print(f"Recommended res = {res:.2f}")
26+
return res
27+
new_sign = 1 if (new_ncluster < ncluster) else -1
28+
if new_sign == old_sign:
29+
res = res + step * old_sign
30+
# print(f"Res changed to {res:.2f}")
31+
old_ncluster = new_ncluster
32+
else:
33+
step = step / 2
34+
# print(f"Step changed to {step:.2f}")
35+
if iter > n_iterations:
36+
# print("Exact resolution not found")
37+
# print(f"Recommended res = {res:.2f}")
38+
return res
39+
iter += 1
40+
# print(f"Recommended res = {res:.2f}")
41+
return res
42+
43+
44+
def search_resolution_latent(
45+
adata: AnnData,
46+
ncluster: int,
47+
*,
48+
start: float = 1,
49+
step: float = 0.1,
50+
n_iterations: int = 15,
51+
**kwargs,
52+
) -> float:
53+
def ncluster4res_leiden(resolution: float) -> int:
54+
sc.tl.leiden(adata, resolution=resolution, **kwargs)
55+
return adata.obs[key_added].cat.categories.size
56+
57+
key_added = kwargs.pop("key_added", "leiden")
58+
59+
return _search_resolution(ncluster4res_leiden, ncluster, start, step, n_iterations)
60+
61+
62+
def search_resolution_spatial(
63+
adata: AnnData,
64+
ncluster: int,
65+
*,
66+
start: float = 0.4,
67+
step: float = 0.1,
68+
n_iterations: int = 15,
69+
**kwargs,
70+
) -> float:
71+
def ncluster4res_spatialleiden(resolution: float) -> int:
72+
spatial_partition_kwargs["resolution_parameter"] = resolution
73+
spatialleiden(
74+
adata,
75+
spatial_partition_kwargs=spatial_partition_kwargs,
76+
key_added=key_added,
77+
**kwargs,
78+
)
79+
return adata.obs[key_added].cat.categories.size
80+
81+
key_added = kwargs.pop("key_added", "spatialleiden")
82+
spatial_partition_kwargs = kwargs.pop("spatial_partition_kwargs", dict())
83+
84+
return _search_resolution(
85+
ncluster4res_spatialleiden, ncluster, start, step, n_iterations
86+
)
87+
88+
89+
def search_resolution(
90+
adata: AnnData,
91+
ncluster: int,
92+
*,
93+
start: tuple[float, float] = (1.0, 0.4),
94+
step: float = 0.1,
95+
n_iterations: int = 15,
96+
latent_kwargs: dict | None = None,
97+
spatial_kwargs: dict | None = None,
98+
) -> tuple[float, float]:
99+
latent_kwargs = dict() if latent_kwargs is None else latent_kwargs
100+
spatial_kwargs = dict() if spatial_kwargs is None else spatial_kwargs
101+
102+
resolution_latent = search_resolution_latent(
103+
adata,
104+
ncluster,
105+
start=start[0],
106+
step=step,
107+
n_iterations=n_iterations,
108+
**latent_kwargs,
109+
)
110+
resolution_spatial = search_resolution_spatial(
111+
adata,
112+
ncluster,
113+
start=start[1],
114+
step=step,
115+
n_iterations=n_iterations,
116+
**spatial_kwargs,
117+
)
118+
return (resolution_latent, resolution_spatial)

0 commit comments

Comments
 (0)