Skip to content

Commit 74941ff

Browse files
authored
Merge pull request #8 from SMD-Bioinformatics-Lund/feature/clustering-methods
Add clustering module with hierarchical and MST-based algorithms
2 parents 7764126 + ad191e7 commit 74941ff

5 files changed

Lines changed: 449 additions & 0 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,35 @@ dependencies = [
1919
]
2020
```
2121

22+
## Clustering module
23+
24+
The library includes a clustering module that provides utilities for grouping samples based on pairwise distances.
25+
26+
It supports two complementary approaches:
27+
- **Hierarchical clustering**, implemented using SciPy linkage methods
28+
- **Minimum spanning tree (MST)–based clustering**, suitable for graph-based visualisation tools such as GrapeTree
29+
30+
Both methods return a unified result type, allowing them to be used interchangeably within services. The results are represented internally as a normalised tree structure, which enables consistent downstream processing.
31+
32+
Clustering outputs can be serialised to Newick format using a shared exporter. For MST-based clustering, an unrooted tree is represented via a synthetic root to ensure compatibility with visualisation tools while preserving the underlying topology.
33+
34+
The module is designed to be reusable across microservices and provides a consistent interface for clustering logic and output formatting.
35+
36+
Example usage
37+
38+
```python
39+
from bonsai_libs.clustering import (
40+
hierarchical_clustering,
41+
)
42+
43+
# Condensed distance matrix and labels
44+
labels = ["A", "B", "C"]
45+
condensed_dm = [1.0, 2.0, 3.0]
46+
47+
result = hierarchical_clustering(condensed_dm, labels)
48+
newick = result.to_newick()
49+
```
50+
2251
## Development
2352

2453
```bash

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"packaging>=25",
1717
"pydantic>=2.6",
1818
"requests>=2.28",
19+
"scipy>=1.17.1",
1920
]
2021

2122
[project.optional-dependencies]

src/bonsai_libs/clustering.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""Shared clustering methods for Bonsai microservices."""
2+
3+
from __future__ import annotations
4+
5+
from collections import defaultdict
6+
from dataclasses import dataclass, field
7+
from enum import StrEnum
8+
from typing import Sequence
9+
10+
from scipy.cluster import hierarchy
11+
from scipy.sparse import csr_array
12+
from scipy.sparse.csgraph import minimum_spanning_tree
13+
from scipy.spatial.distance import squareform
14+
15+
# Data structures
16+
17+
@dataclass(frozen=True)
18+
class Edge:
19+
"""A graph edge connecting nodes."""
20+
21+
target: int
22+
weight: float
23+
24+
25+
Graph = dict[int, list[Edge]]
26+
27+
28+
@dataclass
29+
class ExportNode:
30+
"""
31+
Unified rooted tree node for all clustering outputs.
32+
33+
This is the only structure used for downstream consumption
34+
"""
35+
36+
name: str | None = None
37+
branch_length: float = 0.0
38+
children: list["ExportNode"] = field(default_factory=list)
39+
40+
def is_leaf(self) -> bool:
41+
"""Return true if node is a leaf node."""
42+
return not self.children
43+
44+
45+
# Public API types
46+
47+
48+
class LinkageMethod(StrEnum):
49+
"""Linkage methods for hierarchical clustering."""
50+
51+
SINGLE = "single"
52+
COMPLETE = "complete"
53+
AVERAGE = "average"
54+
WEIGHTED = "weighted"
55+
CENTROID = "centroid"
56+
57+
58+
@dataclass(frozen=True)
59+
class ClusterResult:
60+
"""Result of a clustering operation."""
61+
62+
root: ExportNode
63+
labels: Sequence[str]
64+
65+
def to_newick(self) -> str:
66+
"""Convert the clustering result to Newick format."""
67+
return to_newick(self.root) + ";"
68+
69+
70+
# Hierarchical clustering
71+
72+
73+
def hierarchical_clustering(
74+
condensed_distance_matrix: Sequence[float],
75+
labels: Sequence[str],
76+
*,
77+
method: LinkageMethod = LinkageMethod.SINGLE,
78+
) -> ClusterResult:
79+
"""Perform hierarchial clustering on a condensed distance matrix.
80+
81+
Output is converted into ExportNode for consistency with MST results.
82+
"""
83+
linkage = hierarchy.linkage(condensed_distance_matrix, method=method.value)
84+
scipy_tree = hierarchy.to_tree(linkage, False)
85+
root = _convert_scipy_tree(
86+
scipy_tree,
87+
labels=labels,
88+
parent_dist=float(scipy_tree.dist),
89+
)
90+
91+
return ClusterResult(root=root, labels=labels)
92+
93+
94+
def _convert_scipy_tree(
95+
node,
96+
*,
97+
labels: Sequence[str],
98+
parent_dist: float,
99+
) -> ExportNode:
100+
"""Convert SciPy cluster tree into ExportNode."""
101+
branch_length = float(parent_dist - node.dist)
102+
103+
if node.is_leaf():
104+
return ExportNode(
105+
name=labels[node.id],
106+
branch_length=branch_length,
107+
)
108+
109+
left = _convert_scipy_tree(node.get_left(), labels=labels, parent_dist=node.dist)
110+
right = _convert_scipy_tree(node.get_right(), labels=labels, parent_dist=node.dist)
111+
112+
return ExportNode(
113+
name=None,
114+
branch_length=branch_length,
115+
children=[left, right],
116+
)
117+
118+
119+
# MST clustering
120+
121+
122+
def minimum_spanning_tree_clustering(
123+
condensed_distance_matrix: Sequence[float],
124+
labels: Sequence[str],
125+
*,
126+
root_index: int = 0,
127+
) -> ClusterResult:
128+
"""
129+
Perform MST clustering and convert to rooted export tree.
130+
131+
Suitable for GrapeTree-like visualisation.
132+
"""
133+
if not labels:
134+
raise ValueError("labels must not be empty")
135+
136+
if not (0 <= root_index < len(labels)):
137+
raise ValueError("root_index out of range")
138+
139+
graph = _build_mst_graph(condensed_distance_matrix, size=len(labels))
140+
root = _root_graph(graph, labels, root_index=root_index)
141+
142+
return ClusterResult(root=root, labels=labels)
143+
144+
145+
def _build_mst_graph(
146+
condensed_distance_matrix: Sequence[float],
147+
*,
148+
size: int,
149+
) -> Graph:
150+
"""Build undirected MST graph."""
151+
matrix = csr_array(squareform(condensed_distance_matrix))
152+
mst = minimum_spanning_tree(matrix)
153+
154+
# Make undirected
155+
mst = mst + mst.T
156+
157+
graph: defaultdict[int, list[Edge]] = defaultdict(list)
158+
coo = mst.tocoo()
159+
160+
for i, j, weight in zip(coo.row, coo.col, coo.data):
161+
graph[i].append(Edge(target=j, weight=float(weight)))
162+
163+
# Ensure all nodes exist (defensive)
164+
for i in range(size):
165+
graph.setdefault(i, [])
166+
167+
return dict(graph)
168+
169+
170+
def _root_graph(
171+
graph: Graph,
172+
labels: Sequence[str],
173+
root_index: int | None = None,
174+
parent: int | None = None,
175+
branch_length: float = 0.0,
176+
) -> ExportNode:
177+
"""Convert graph into rooted tree."""
178+
179+
if not root_index:
180+
# Use synthetic root
181+
root_index = 0
182+
root = ExportNode(name=None, branch_length=0.0)
183+
root.children.append(
184+
ExportNode(
185+
name=labels[root_index],
186+
branch_length=0.0,
187+
)
188+
)
189+
else:
190+
root = ExportNode(name=labels[root_index], branch_length=branch_length)
191+
192+
# Build graph
193+
for edge in graph.get(root_index, []):
194+
# Prevent traversing back to parent
195+
if edge.target == parent:
196+
continue
197+
198+
child = _root_graph(
199+
graph,
200+
labels,
201+
root_index=edge.target,
202+
parent=root_index,
203+
branch_length=edge.weight,
204+
)
205+
root.children.append(child)
206+
207+
return root
208+
209+
210+
# Newick export
211+
212+
213+
def to_newick(node: ExportNode) -> str:
214+
"""Convert ExportNode tree to Newick format."""
215+
216+
if node.is_leaf():
217+
if node.name is None:
218+
raise ValueError("Leaf node missing name")
219+
return f"{node.name}:{node.branch_length:.6f}"
220+
221+
children_str = ",".join(to_newick(child) for child in node.children)
222+
223+
# Internal node naming is optional
224+
if node.name:
225+
if node.branch_length > 0:
226+
return f"({children_str}){node.name}:{node.branch_length:.6f}"
227+
return f"({children_str}){node.name}"
228+
229+
if node.branch_length > 0:
230+
return f"({children_str}):{node.branch_length:.6f}"
231+
232+
return f"({children_str})"

tests/conftest.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,53 @@ def clear_registry_before_each_test():
1919
yield
2020
_PARSER_REGISTRY.clear()
2121
_RESULT_MODEL_REGISTRY.clear()
22+
23+
24+
@pytest.fixture()
25+
def small_distance_matrix() -> tuple[list[float], list[str]]:
26+
"""
27+
Create a simple 3-sample condensed distance matrix.
28+
29+
Distances:
30+
A-B = 1
31+
A-C = 2
32+
B-C = 3
33+
"""
34+
labels = ["A", "B", "C"]
35+
condensed = [1.0, 2.0, 3.0]
36+
return condensed, labels
37+
38+
39+
@pytest.fixture
40+
def medium_distance_matrix():
41+
"""
42+
Create a more complex condensed distance matrix
43+
44+
Distances:
45+
A-B = 3
46+
A-C = 4.5
47+
A-D = 5.5
48+
A-E = 13
49+
B-C = 5.5
50+
B-D = 6.5
51+
B-E = 14
52+
C-D = 7
53+
C-E = 15.5
54+
D-E = 16.5
55+
56+
"""
57+
58+
labels = ["A", "B", "C", "D", "E"]
59+
condensed = [
60+
3,
61+
4,
62+
5,
63+
13,
64+
5,
65+
6,
66+
14,
67+
7,
68+
15,
69+
16,
70+
]
71+
return condensed, labels

0 commit comments

Comments
 (0)