phytclust 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phytclust/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from .algo.core import PhytClust
2
+ from importlib.metadata import version, PackageNotFoundError
3
+
4
+ __all__ = ["PhytClust"]
5
+
6
+ try:
7
+ __version__ = version("phytclust")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0"
phytclust/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,3 @@
1
+ from .core import PhytClust
2
+
3
+ __all__ = ["PhytClust"]
phytclust/algo/bins.py ADDED
@@ -0,0 +1,30 @@
1
+ from typing import Optional, List, Tuple
2
+ import numpy as np
3
+
4
+
5
+ def define_bins(
6
+ pc,
7
+ num_bins: int = 3,
8
+ *,
9
+ k_lo: int = 1,
10
+ k_hi: Optional[int] = None,
11
+ ) -> List[Tuple[int, int]]:
12
+ """Return num_bins log-spaced (inclusive) ranges covering [k_lo … k_hi]."""
13
+ if k_hi is None:
14
+ k_hi = pc.num_terminals
15
+ if k_hi <= k_lo:
16
+ raise ValueError("k_hi must be > k_lo")
17
+
18
+ raw = np.geomspace(k_lo, k_hi, num_bins + 1)
19
+ edges = np.unique(np.round(raw).astype(int))
20
+ edges[0], edges[-1] = k_lo, k_hi
21
+ edges = np.maximum.accumulate(edges)
22
+
23
+ bins = []
24
+ for i in range(len(edges) - 1):
25
+ lo = edges[i] if i == 0 else bins[-1][1] + 1
26
+ hi = edges[i + 1]
27
+ if lo > hi:
28
+ continue
29
+ bins.append((lo, hi))
30
+ return bins
@@ -0,0 +1,160 @@
1
+ # bootstrap_stability.py
2
+ from collections import defaultdict
3
+ from typing import List, Dict, Any, Optional
4
+
5
+ import numpy as np
6
+
7
+ from .core import PhytClust # your class
8
+
9
+
10
+ def _taxon_order(trees):
11
+ """
12
+ Get a sorted, consistent list of taxa present in *all* trees (intersection).
13
+ You can choose union instead if you want, but intersection is safer.
14
+ """
15
+ sets = []
16
+ for t in trees:
17
+ leaves = [term.name for term in t.get_terminals()]
18
+ sets.append(set(leaves))
19
+
20
+ common = set.intersection(*sets)
21
+ if not common:
22
+ raise ValueError("No common taxa across bootstrap trees.")
23
+
24
+ return sorted(common)
25
+
26
+
27
+ def _labels_from_cmap(cmap, taxa, missing_label: int = -1):
28
+ """
29
+ Convert {leaf_obj -> cluster_id} into a label vector aligned with `taxa` list.
30
+ Assumes cmap keys are leaf objects with .name.
31
+ """
32
+ name_to_cluster = {leaf.name: cid for leaf, cid in cmap.items()}
33
+ labels = np.full(len(taxa), missing_label, dtype=int)
34
+ for i, name in enumerate(taxa):
35
+ if name in name_to_cluster:
36
+ labels[i] = name_to_cluster[name]
37
+ return labels
38
+
39
+
40
+ def compute_coassoc_for_k(
41
+ trees,
42
+ k: int,
43
+ *,
44
+ outgroup: Optional[str] = None,
45
+ min_cluster_size: int = 1,
46
+ pc_kwargs: Optional[Dict[str, Any]] = None,
47
+ ):
48
+ """
49
+ For a given k, run PhytClust on each bootstrap tree and compute:
50
+
51
+ - labels: (B, N) int array of cluster IDs (or -1 for missing)
52
+ - coassoc: (N, N) float array of co-association frequencies
53
+ - taxa: list of taxon names in order
54
+ """
55
+ if pc_kwargs is None:
56
+ pc_kwargs = {}
57
+
58
+ taxa = _taxon_order(trees)
59
+ B = len(trees)
60
+ N = len(taxa)
61
+ labels = np.full((B, N), -1, dtype=int)
62
+
63
+ for b, tree in enumerate(trees):
64
+ pc = PhytClust(
65
+ tree=tree, outgroup=outgroup, min_cluster_size=min_cluster_size, **pc_kwargs
66
+ )
67
+ res = pc.run(k=k)
68
+ cmap = res["clusters"] # {leaf_obj -> cluster_id}
69
+ labels[b, :] = _labels_from_cmap(cmap, taxa)
70
+
71
+ coassoc = np.zeros((N, N), dtype=float)
72
+ counts = np.zeros((N, N), dtype=float)
73
+
74
+ for b in range(B):
75
+ row = labels[b]
76
+ for i in range(N):
77
+ if row[i] < 0:
78
+ continue
79
+ for j in range(i + 1, N):
80
+ if row[j] < 0:
81
+ continue
82
+ counts[i, j] += 1
83
+ counts[j, i] += 1
84
+ if row[i] == row[j]:
85
+ coassoc[i, j] += 1
86
+ coassoc[j, i] += 1
87
+
88
+ mask = counts > 0
89
+ coassoc[mask] = coassoc[mask] / counts[mask]
90
+ np.fill_diagonal(coassoc, 1.0)
91
+
92
+ return taxa, labels, coassoc
93
+
94
+
95
+ def stability_for_k(coassoc: np.ndarray) -> float:
96
+ """
97
+ Simple example: average co-association over all pairs.
98
+ You can design something more clever later.
99
+ """
100
+ N = coassoc.shape[0]
101
+ # exclude diagonal
102
+ triu_idx = np.triu_indices(N, k=1)
103
+ vals = coassoc[triu_idx]
104
+ if vals.size == 0:
105
+ return 0.0
106
+ return float(np.mean(vals))
107
+
108
+
109
+ def choose_k_by_stability(
110
+ trees,
111
+ k_values: List[int],
112
+ *,
113
+ outgroup: Optional[str] = None,
114
+ min_cluster_size: int = 1,
115
+ pc_kwargs: Optional[Dict[str, Any]] = None,
116
+ ):
117
+ """
118
+ For each k in k_values, compute co-association and a stability score.
119
+ Return k* with max stability and all metadata.
120
+
121
+ Returns dict:
122
+ {
123
+ "best_k": k_star,
124
+ "scores": {k: score_k, ...},
125
+ "coassoc": {k: coassoc_matrix, ...},
126
+ "taxa": [...],
127
+ }
128
+ """
129
+ scores = {}
130
+ coassoc_by_k = {}
131
+ taxa_ref = None
132
+
133
+ for k in k_values:
134
+ taxa, labels, coassoc = compute_coassoc_for_k(
135
+ trees,
136
+ k,
137
+ outgroup=outgroup,
138
+ min_cluster_size=min_cluster_size,
139
+ pc_kwargs=pc_kwargs,
140
+ )
141
+ if taxa_ref is None:
142
+ taxa_ref = taxa
143
+ else:
144
+ if taxa != taxa_ref:
145
+ raise ValueError(
146
+ "Taxon order mismatch across k; this should not happen."
147
+ )
148
+
149
+ score_k = stability_for_k(coassoc)
150
+ scores[k] = score_k
151
+ coassoc_by_k[k] = coassoc
152
+
153
+ best_k = max(scores, key=scores.get)
154
+ return {
155
+ "best_k": best_k,
156
+ "scores": scores,
157
+ "coassoc": coassoc_by_k,
158
+ "taxa": taxa_ref,
159
+ }
160
+
phytclust/algo/core.py ADDED
@@ -0,0 +1,442 @@
1
+ import logging
2
+ from dataclasses import dataclass, field
3
+ from math import ceil
4
+ from typing import Any, Optional, Dict, List
5
+
6
+ import numpy as np
7
+ from pathlib import Path
8
+ from io import StringIO
9
+
10
+ from Bio import Phylo
11
+ from Bio.Phylo.BaseTree import Tree
12
+
13
+ from ..algo.dp import (
14
+ validate_args,
15
+ prepare_tree,
16
+ compute_dp_table,
17
+ backtrack,
18
+ )
19
+ from ..algo.scoring import calculate_scores, find_score_peaks
20
+ from ..viz.cluster_plot import plot_clusters
21
+ from ..io.save_results import save_clusters
22
+
23
+ logger = logging.getLogger("phytclust")
24
+
25
+ IntMap = Dict[Any, int]
26
+
27
+
28
+ def _coerce_to_tree(obj: Any) -> Tree:
29
+ """
30
+ Accepts:
31
+ - Bio.Phylo.BaseTree.Tree → returned as-is
32
+ - pathlib.Path → read as Newick
33
+ - str:
34
+ * if it looks like a file path and exists → read as Newick file
35
+ * otherwise → treat as a Newick string
36
+
37
+ Raises TypeError if the object cannot be interpreted as a tree.
38
+ """
39
+ if isinstance(obj, Tree):
40
+ return obj
41
+
42
+ if isinstance(obj, Path):
43
+ return Phylo.read(str(obj), "newick")
44
+
45
+ if isinstance(obj, str):
46
+ candidate = Path(obj)
47
+ if candidate.exists() and not any(ch in obj for ch in "() ;"):
48
+ return Phylo.read(str(candidate), "newick")
49
+
50
+ handle = StringIO(obj)
51
+ return Phylo.read(handle, "newick")
52
+
53
+ raise TypeError(
54
+ f"Unsupported tree input type: {type(obj)!r}. "
55
+ "Expected a Bio.Phylo Tree, a Newick string, or a path to a Newick file."
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class PhytClust:
61
+ """
62
+ Dynamic-programming phylogenetic clustering.
63
+
64
+ Parameters
65
+ ----------
66
+ tree : Bio.Phylo.BaseTree.Tree
67
+ The input tree (you can pass a Newick string/file upstream).
68
+ outgroup : str | None, default=None
69
+ Taxon to exclude from all clusters (treated as outgroup).
70
+ min_cluster_size : int, default=1
71
+ Hard constraint: final clusters smaller than this are disallowed.
72
+ k : int | None, default=None
73
+ Fixed number of clusters (only used when you call `run(k=...)`
74
+ or `get_clusters(k)` explicitly).
75
+ max_k : int | None, default=None
76
+ Upper bound on k for scoring / peak search. If None, derived
77
+ from `max_k_limit * num_terminals`.
78
+ max_k_limit : float, default=0.9
79
+ When `max_k` is not set, `max_k` = ceil(max_k_limit * num_terminals).
80
+ resolution_on : bool, default=False
81
+ Internal flag for multi-resolution peak search.
82
+ num_peaks : int, default=3
83
+ Number of global peaks to report in best_global.
84
+ num_bins : int, default=3
85
+ Number of log-resolution bins for best_by_resolution.
86
+
87
+ Support / branch-length tuning
88
+ ------------------------------
89
+ use_branch_support : bool, default=False
90
+ If True, branch supports are incorporated into effective branch
91
+ lengths and internal split penalties.
92
+ min_support : float, default=0.05
93
+ Minimal support used when normalizing (avoid division by 0).
94
+ support_weight : float, default=1.0
95
+ Weight of the support-derived penalty in `_eff_length`.
96
+
97
+ Outlier handling (soft constraint)
98
+ ----------------------------------
99
+ outlier_size_threshold : int | None, default=None
100
+ If set, clusters with size < threshold incur `outlier_penalty`
101
+ added to their cost (soft penalty, not a hard constraint).
102
+ outlier_penalty : float, default=0.0
103
+ Additive penalty for clusters smaller than outlier_size_threshold.
104
+
105
+ Other flags
106
+ -----------
107
+ compute_all_clusters : bool, default=False
108
+ If True in best_global, compute and cache all k clusterings up to max_k.
109
+ drop_outliers : bool, default=False
110
+ Reserved flag (used downstream in plotting/saving).
111
+ """
112
+
113
+ tree: Any
114
+ outgroup: Optional[str] = None
115
+ min_cluster_size: int = 1
116
+ k: Optional[int] = None
117
+ max_k: Optional[int] = None
118
+ max_k_limit: float = 0.9
119
+ resolution_on: bool = False
120
+ num_peaks: int = 3
121
+ num_bins: int = 3
122
+
123
+ # tunables
124
+ use_branch_support: bool = False
125
+ min_support: float = 0.05
126
+ support_weight: float = 1.0
127
+
128
+ # outlier penalties
129
+ outlier_size_threshold: Optional[int] = None
130
+ outlier_penalty: float = 0.0
131
+
132
+ compute_all_clusters: bool = False
133
+ drop_outliers: bool = False
134
+
135
+ def __post_init__(self) -> None:
136
+ self.tree = _coerce_to_tree(self.tree)
137
+
138
+ self.name_leaves_per_node = {}
139
+ self.num_leaves_per_node = {}
140
+ self.backptr = {}
141
+ self.dp_table = None
142
+ self.postorder_nodes = None
143
+ self.node_to_id = None
144
+ self.num_terminals = 0
145
+ self._tree_wo_outgroup = None
146
+
147
+ self.scores = None
148
+ self.peaks_by_rank = None
149
+
150
+ self._dp_ready = False
151
+ self._tree_hash: Optional[int] = None
152
+ self.clusters: Dict[int, IntMap] = {}
153
+ self._last_result: Optional[Dict[str, Any]] = None
154
+
155
+ def _hash_tree(self) -> int:
156
+ """tree fingerprint, used to detect modifications."""
157
+ try:
158
+ return hash(self.tree.format("newick"))
159
+ except Exception:
160
+ return hash(repr(self.tree))
161
+
162
+ def _ensure_dp(self) -> None:
163
+ current = self._hash_tree()
164
+
165
+ if self._dp_ready and self._tree_hash == current:
166
+ return
167
+
168
+ validate_args(self)
169
+ prepare_tree(self)
170
+ compute_dp_table(self)
171
+
172
+ self._dp_ready = True
173
+ self._tree_hash = current
174
+
175
+ self.clusters = {}
176
+ self.scores = None
177
+ self.peaks_by_rank = None
178
+
179
+ if self.max_k is None or self.max_k < 1:
180
+ self.max_k = max(2, ceil(self.num_terminals * self.max_k_limit))
181
+
182
+ # explicit k
183
+ def get_clusters(self, k: int, *, verbose: bool = False) -> Dict[Any, int]:
184
+ """
185
+ Exact k-cluster partition
186
+ """
187
+ if k is None:
188
+ raise ValueError("Please provide k")
189
+ if k < 1:
190
+ raise ValueError("k must be ≥ 1")
191
+
192
+ self.k = k
193
+ cmap = backtrack(self, k, verbose=verbose)
194
+
195
+ if self.clusters is None:
196
+ self.clusters = {}
197
+ self.clusters[k] = cmap
198
+ return cmap
199
+
200
+ # optimise globally
201
+ def best_global(
202
+ self,
203
+ *,
204
+ top_n: int = 1,
205
+ max_k: Optional[int] = None,
206
+ max_k_limit: Optional[float] = None,
207
+ plot_scores: bool = True,
208
+ compute_all_clusters: bool = False,
209
+ alpha: float = 0.7,
210
+ ) -> List[Dict[Any, int]]:
211
+ """
212
+ cluster-validity index-based global peak search.
213
+
214
+ Returns a list of cluster maps in peak-rank order.
215
+ """
216
+ self._ensure_dp()
217
+
218
+ self.max_k_limit = max_k_limit if max_k_limit is not None else 0.9
219
+ self.max_k = max_k or max(2, ceil(self.num_terminals * self.max_k_limit))
220
+
221
+ calculate_scores(self, plot=plot_scores)
222
+ logger.debug(
223
+ f"score vector length = {0 if self.scores is None else len(self.scores)}"
224
+ )
225
+ logger.debug(f"scores = {self.scores}")
226
+
227
+ if self.scores is None or len(self.scores) == 0:
228
+ try:
229
+ cmap = self.get_clusters(2)
230
+ self.clusters = {2: cmap}
231
+ self.peaks_by_rank = [2]
232
+ self.k = None
233
+ return [cmap]
234
+ except Exception:
235
+ self.clusters = {}
236
+ self.peaks_by_rank = []
237
+ self.k = None
238
+ return []
239
+
240
+ score_len = min(self.max_k, len(self.scores))
241
+ find_score_peaks(
242
+ self,
243
+ global_peaks=top_n,
244
+ resolution_on=False,
245
+ k_start=2,
246
+ k_end=score_len,
247
+ plot=plot_scores,
248
+ alpha=alpha,
249
+ )
250
+
251
+ self.clusters = {}
252
+ if compute_all_clusters:
253
+ for k_val in range(1, self.max_k + 1):
254
+ self.get_clusters(k_val)
255
+ else:
256
+ for k_val in self.peaks_by_rank or []:
257
+ self.get_clusters(k_val)
258
+
259
+ self.k = None
260
+ return [
261
+ self.clusters[kv]
262
+ for kv in (self.peaks_by_rank or [])
263
+ if kv in self.clusters
264
+ ]
265
+
266
+ # choose best solutions for different clade levels
267
+ def best_by_resolution(
268
+ self,
269
+ *,
270
+ num_bins: int = 3,
271
+ max_k: Optional[int] = None,
272
+ plot_scores: bool = True,
273
+ alpha: float = 0.7,
274
+ ) -> List[Dict[Any, int]]:
275
+ """
276
+ Multi-resolution mode: one peak per log-bin.
277
+
278
+ Returns a list of cluster maps, one per selected k.
279
+ """
280
+ self._ensure_dp()
281
+
282
+ self.max_k = max_k or ceil(self.num_terminals * self.max_k_limit)
283
+ calculate_scores(self, plot=plot_scores)
284
+
285
+ score_k_count = min(self.max_k, len(self.scores))
286
+
287
+ if score_k_count < 50:
288
+ logger.info(
289
+ "Tree too small for resolution mode, finding globally optimal solution instead."
290
+ )
291
+ return self.best_global(
292
+ top_n=min(self.num_peaks, score_k_count - 1),
293
+ max_k=self.max_k,
294
+ max_k_limit=self.max_k_limit,
295
+ plot_scores=plot_scores,
296
+ compute_all_clusters=False,
297
+ alpha=alpha,
298
+ )
299
+
300
+ score_len = score_k_count - 1
301
+
302
+ find_score_peaks(
303
+ self,
304
+ resolution_on=True,
305
+ num_bins=num_bins,
306
+ peaks_per_bin=1,
307
+ k_start=2,
308
+ k_end=score_len,
309
+ plot=plot_scores,
310
+ alpha=alpha,
311
+ )
312
+
313
+ self.clusters = {}
314
+ for k_val in self.peaks_by_rank or []:
315
+ self.get_clusters(k_val)
316
+
317
+ self.k = None
318
+ return [
319
+ self.clusters[kv]
320
+ for kv in (self.peaks_by_rank or [])
321
+ if kv in self.clusters
322
+ ]
323
+
324
+ def run(
325
+ self,
326
+ *,
327
+ k: Optional[int] = None,
328
+ top_n: int = 1,
329
+ by_resolution: bool = False,
330
+ num_bins: Optional[int] = None,
331
+ max_k: Optional[int] = None,
332
+ max_k_limit: Optional[float] = None,
333
+ plot_scores: bool = True,
334
+ alpha: float = 0.7,
335
+ ) -> Dict[str, Any]:
336
+ """
337
+ Unified high-level entry point.
338
+
339
+ Modes
340
+ -----
341
+ 1. Exact k:
342
+ pc.run(k=5)
343
+
344
+ 2. Global peaks (CalBow):
345
+ pc.run(top_n=3)
346
+
347
+ 3. Multi-resolution peaks (one per log-bin):
348
+ pc.run(by_resolution=True, num_bins=3)
349
+ """
350
+ # validation
351
+ k_val = k if k is not None else self.k
352
+ if k_val is not None:
353
+ if k_val < 1:
354
+ raise ValueError("k must be ≥ 1.")
355
+ if by_resolution:
356
+ raise ValueError("Cannot combine `k` with `by_resolution=True`.")
357
+ if top_n != 1:
358
+ raise ValueError("`top_n` is meaningless when `k` is given.")
359
+ if num_bins is not None:
360
+ logger.warning("`num_bins` ignored when `k` is specified.")
361
+
362
+ # exact-k mode
363
+ self._ensure_dp()
364
+ cmap = self.get_clusters(k_val)
365
+ result = {
366
+ "mode": "k",
367
+ "k": k_val,
368
+ "clusters": cmap,
369
+ "scores": None,
370
+ "peaks": [k_val],
371
+ }
372
+ self._last_result = result
373
+ return result
374
+
375
+ if top_n < 1:
376
+ raise ValueError("`top_n` must be ≥ 1.")
377
+
378
+ if by_resolution:
379
+ if num_bins is None:
380
+ num_bins = self.num_bins
381
+ if max_k_limit is not None:
382
+ self.max_k_limit = max_k_limit
383
+
384
+ clusters = self.best_by_resolution(
385
+ num_bins=num_bins,
386
+ max_k=max_k,
387
+ plot_scores=plot_scores,
388
+ alpha=alpha,
389
+ )
390
+ result = {
391
+ "mode": "resolution",
392
+ "ks": list(self.peaks_by_rank or []),
393
+ "clusters": clusters,
394
+ "scores": None if self.scores is None else self.scores.copy(),
395
+ "peaks": list(self.peaks_by_rank or []),
396
+ }
397
+ self._last_result = result
398
+ return result
399
+
400
+ # global peak mode
401
+ if max_k_limit is None:
402
+ max_k_limit = self.max_k_limit
403
+
404
+ clusters = self.best_global(
405
+ top_n=top_n,
406
+ max_k=max_k,
407
+ max_k_limit=max_k_limit,
408
+ plot_scores=plot_scores,
409
+ compute_all_clusters=False,
410
+ alpha=alpha,
411
+ )
412
+ result = {
413
+ "mode": "global",
414
+ "ks": list(self.peaks_by_rank or []),
415
+ "clusters": clusters,
416
+ "scores": None if self.scores is None else self.scores.copy(),
417
+ "peaks": list(self.peaks_by_rank or []),
418
+ }
419
+ self._last_result = result
420
+ return result
421
+
422
+ def plot(self, results_dir: Optional[str] = None, **kwargs) -> None:
423
+ plot_clusters(self, results_dir=results_dir, **kwargs)
424
+
425
+ def save(
426
+ self,
427
+ results_dir: str,
428
+ top_n: int = 1,
429
+ filename: str = "phytclust_results.tsv",
430
+ outlier: bool = True,
431
+ n: Optional[int] = None,
432
+ output_all: bool = False,
433
+ ) -> None:
434
+ save_clusters(
435
+ self,
436
+ results_dir=results_dir,
437
+ top_n=top_n,
438
+ filename=filename,
439
+ outlier=outlier,
440
+ n=n,
441
+ output_all=output_all,
442
+ )