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 +9 -0
- phytclust/__main__.py +4 -0
- phytclust/algo/__init__.py +3 -0
- phytclust/algo/bins.py +30 -0
- phytclust/algo/bootstrap_stability.py +160 -0
- phytclust/algo/core.py +442 -0
- phytclust/algo/dp.py +287 -0
- phytclust/algo/scoring.py +331 -0
- phytclust/cli/__init__.py +3 -0
- phytclust/cli/__main__.py +5 -0
- phytclust/cli/_cli.py +461 -0
- phytclust/cli/ascii_logo.txt +8 -0
- phytclust/io/__init__.py +0 -0
- phytclust/io/save_results.py +67 -0
- phytclust/metrics/__init__.py +39 -0
- phytclust/metrics/indices.py +179 -0
- phytclust/phytclust_logo_colour.png +0 -0
- phytclust/phytclust_logo_colour_text_only.png +0 -0
- phytclust/selection/__init__.py +0 -0
- phytclust/selection/pd_representatives.py +175 -0
- phytclust/small_logo.png +0 -0
- phytclust/utils/__init__.py +15 -0
- phytclust/utils/tree.py +96 -0
- phytclust/validation.py +209 -0
- phytclust/viz/__init__.py +0 -0
- phytclust/viz/cluster_plot.py +80 -0
- phytclust/viz/plotting.py +679 -0
- phytclust/viz/scores_plot.py +192 -0
- phytclust-0.1.0.dist-info/METADATA +786 -0
- phytclust-0.1.0.dist-info/RECORD +34 -0
- phytclust-0.1.0.dist-info/WHEEL +5 -0
- phytclust-0.1.0.dist-info/entry_points.txt +2 -0
- phytclust-0.1.0.dist-info/licenses/LICENSE +674 -0
- phytclust-0.1.0.dist-info/top_level.txt +1 -0
phytclust/__init__.py
ADDED
phytclust/__main__.py
ADDED
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
|
+
)
|