mmm4microbiome 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.
- mmm4microbiome/__init__.py +47 -0
- mmm4microbiome/_base.py +352 -0
- mmm4microbiome/assets/onestop_template.html +1278 -0
- mmm4microbiome/compare.py +437 -0
- mmm4microbiome/da_barplot.py +287 -0
- mmm4microbiome/features.py +194 -0
- mmm4microbiome/onestop.py +271 -0
- mmm4microbiome/sample.py +570 -0
- mmm4microbiome/sankey.py +248 -0
- mmm4microbiome-0.1.0.dist-info/METADATA +111 -0
- mmm4microbiome-0.1.0.dist-info/RECORD +13 -0
- mmm4microbiome-0.1.0.dist-info/WHEEL +4 -0
- mmm4microbiome-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mmm4microbiome: read a mixed-membership sweep as microbiome.
|
|
3
|
+
|
|
4
|
+
Downstream of ``stripesankey`` (>= 0.4.1): ``stripesankey.prepare`` and
|
|
5
|
+
``stripesankey.sweep`` write a base folder, and everything here reads it. A
|
|
6
|
+
component is called **MC** (microbial component), and its H row is mapped to
|
|
7
|
+
taxa through ``feature_annotation.csv``. Fitting, consensus and the Sankey
|
|
8
|
+
widget itself stay in stripesankey::
|
|
9
|
+
|
|
10
|
+
import stripesankey as ss
|
|
11
|
+
import mmm4microbiome as mmm
|
|
12
|
+
|
|
13
|
+
ss.prepare(base, counts, feature_annotation, metadata, level="Genus")
|
|
14
|
+
ss.sweep(base, k_range=range(2, 11))
|
|
15
|
+
|
|
16
|
+
mmm.stripesankey_mb(base, metadata_df=meta, hover_field="Country")
|
|
17
|
+
mmm.heatmap_mc(base, 5)
|
|
18
|
+
mmm.compare_groups(base, "K5MC2", "K5MC0")
|
|
19
|
+
mmm.onestop(base, meta)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
from ._base import (dominant_samples, fit_level, k_values, load_clusters, load_counts,
|
|
25
|
+
load_mc, mc_feature_table, parse_mc, taxonomy_for_features)
|
|
26
|
+
from .sankey import stripesankey_mb, metasankey_mb
|
|
27
|
+
from .sample import SampleHeatmapInline, heatmap_sample, bar_sample
|
|
28
|
+
from .features import heatmap_mc, mc_profile
|
|
29
|
+
from .da_barplot import da_barplot
|
|
30
|
+
from .compare import compare_groups, CompareReport, DAA_METHODS
|
|
31
|
+
from .onestop import onestop
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
# the base folder
|
|
35
|
+
"k_values", "fit_level", "load_mc", "load_clusters", "load_counts",
|
|
36
|
+
"mc_feature_table", "taxonomy_for_features", "parse_mc", "dominant_samples",
|
|
37
|
+
# the sweep
|
|
38
|
+
"stripesankey_mb", "metasankey_mb",
|
|
39
|
+
# one k, sample by sample
|
|
40
|
+
"SampleHeatmapInline", "heatmap_sample", "bar_sample",
|
|
41
|
+
# what the MCs are made of
|
|
42
|
+
"heatmap_mc", "mc_profile",
|
|
43
|
+
# two groups compared
|
|
44
|
+
"compare_groups", "CompareReport", "DAA_METHODS", "da_barplot",
|
|
45
|
+
# everything in one page
|
|
46
|
+
"onestop",
|
|
47
|
+
]
|
mmm4microbiome/_base.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read the base folder stripesankey writes, the microbiome way.
|
|
3
|
+
|
|
4
|
+
``stripesankey.prepare`` + ``stripesankey.sweep`` leave one folder per
|
|
5
|
+
analysis::
|
|
6
|
+
|
|
7
|
+
base/intermediate/features.csv sample x feature counts that were fitted
|
|
8
|
+
base/intermediate/feature_annotation.csv feature id x taxonomic ranks
|
|
9
|
+
base/intermediate/manifest.json the level the counts were collapsed to
|
|
10
|
+
base/results/W/W_{k}.csv sample x LC loadings, one table per k
|
|
11
|
+
base/results/H/H_{k}.csv LC x feature profiles, one table per k
|
|
12
|
+
base/results/consensus.json the consensus clusters across k
|
|
13
|
+
base/results/run_info.json k list, parameters, convergence
|
|
14
|
+
|
|
15
|
+
Everything in mmm4microbiome reads through this module. A component (a column
|
|
16
|
+
of W, a row of H) is called LC upstream and **MC** (microbial component) here.
|
|
17
|
+
|
|
18
|
+
Two taxonomic levels are involved, and they are different things:
|
|
19
|
+
|
|
20
|
+
* the **fit level** -- what ``prepare(level=)`` collapsed the counts to, i.e.
|
|
21
|
+
the features the model was fitted on (``level=None`` keeps one feature per
|
|
22
|
+
ASV);
|
|
23
|
+
* the **display level** -- ``feature_level`` in this package: what a figure
|
|
24
|
+
names. By default the fit level, except that an ASV (or Species) fit is
|
|
25
|
+
shown at Genus. A coarser level sums its child probabilities; a level finer
|
|
26
|
+
than the fit cannot be recovered and falls back to the fit level.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import warnings
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
import pandas as pd
|
|
38
|
+
|
|
39
|
+
#: Ranks from fine to coarse; a display level can only be at or above the fit.
|
|
40
|
+
_RANKS = ["ASV", "Species", "Genus", "Family", "Order", "Class", "Phylum", "Kingdom", "Domain"]
|
|
41
|
+
#: Annotation columns that hold one value per ASV, i.e. an ASV-level fit.
|
|
42
|
+
_ASV_COLUMNS = {"genus_id", "randomid", "asv", "feature id", "feature_id"}
|
|
43
|
+
_RANK_PREFIX = re.compile(r"^[a-z]__")
|
|
44
|
+
_LC_LABEL = re.compile(r"[LMlm][Cc]\s*(\d+)$")
|
|
45
|
+
# 'K5MC2', 'K5_MC2', 'k5 mc2', 'K=5:MC2', ...
|
|
46
|
+
_MC_SELECTOR = re.compile(r"^\s*[Kk]?\s*[=:]?\s*(\d+)\s*[_ :=\-]*[Mm][Cc]\s*[=:]?\s*(\d+)\s*$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------- #
|
|
50
|
+
# JSON helpers
|
|
51
|
+
# --------------------------------------------------------------------------- #
|
|
52
|
+
def _np_default(o):
|
|
53
|
+
if isinstance(o, np.generic):
|
|
54
|
+
return o.item()
|
|
55
|
+
if isinstance(o, np.ndarray):
|
|
56
|
+
return o.tolist()
|
|
57
|
+
raise TypeError(f"not JSON serialisable: {type(o).__name__}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _json_safe(obj):
|
|
61
|
+
"""Round-trip through JSON so numpy scalars / arrays become plain Python."""
|
|
62
|
+
return json.loads(json.dumps(obj, default=_np_default))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------- #
|
|
66
|
+
# the base folder
|
|
67
|
+
# --------------------------------------------------------------------------- #
|
|
68
|
+
def _require_sweep(base) -> None:
|
|
69
|
+
if not os.path.isdir(os.path.join(str(base), "results", "W")):
|
|
70
|
+
raise FileNotFoundError(
|
|
71
|
+
f"{base} has no results/W, so it is not a stripesankey base folder yet. "
|
|
72
|
+
"Run stripesankey.prepare(base, counts, feature_annotation, metadata) and "
|
|
73
|
+
"stripesankey.sweep(base, k_range=...) first.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def k_values(base) -> list[int]:
|
|
77
|
+
"""The k values with a ``results/W/W_{k}.csv`` in ``base``."""
|
|
78
|
+
_require_sweep(base)
|
|
79
|
+
wdir = os.path.join(str(base), "results", "W")
|
|
80
|
+
found = (re.fullmatch(r"W_(\d+)\.csv", name) for name in os.listdir(wdir))
|
|
81
|
+
return sorted(int(m.group(1)) for m in found if m)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _check_k(base, k) -> int:
|
|
85
|
+
k = int(k)
|
|
86
|
+
ks = k_values(base)
|
|
87
|
+
if k not in ks:
|
|
88
|
+
raise KeyError(f"k={k} is not in {base}; the sweep has k={ks}")
|
|
89
|
+
return k
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _mc_index(label, fallback: int) -> int:
|
|
93
|
+
"""'LC3' / 'MC3' / 'K5_MC3' -> 3; any other label -> its position."""
|
|
94
|
+
m = _LC_LABEL.search(str(label))
|
|
95
|
+
return int(m.group(1)) if m else fallback
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_mc(base, k) -> pd.DataFrame:
|
|
99
|
+
"""W at one k as a sample x MC table (columns ``MC0 .. MC{k-1}``).
|
|
100
|
+
|
|
101
|
+
Values are as stored; ``stripesankey.sweep`` writes rows that sum to 1.
|
|
102
|
+
"""
|
|
103
|
+
import stripesankey as ss
|
|
104
|
+
|
|
105
|
+
k = _check_k(base, k)
|
|
106
|
+
W = ss.load_W(str(base), [k])[k].copy()
|
|
107
|
+
W.index = W.index.map(str)
|
|
108
|
+
W.columns = [f"MC{_mc_index(c, i)}" for i, c in enumerate(W.columns)]
|
|
109
|
+
return W
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_clusters(base) -> dict:
|
|
113
|
+
"""``{sample: cluster}`` from ``results/consensus.json`` (``-1`` = noise); ``{}`` if absent."""
|
|
114
|
+
import stripesankey as ss
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
s2c = ss.load_consensus(str(base)).get("sample_to_cluster") or {}
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
return {}
|
|
120
|
+
return {str(s): int(c) for s, c in s2c.items()}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _manifest(base) -> dict:
|
|
124
|
+
import stripesankey as ss
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
return ss.load_manifest(str(base))
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _annotation(base) -> pd.DataFrame | None:
|
|
133
|
+
path = os.path.join(str(base), "intermediate", "feature_annotation.csv")
|
|
134
|
+
if not os.path.exists(path):
|
|
135
|
+
return None
|
|
136
|
+
ann = pd.read_csv(path, index_col=0)
|
|
137
|
+
ann.index = ann.index.map(str)
|
|
138
|
+
return ann
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_counts(base) -> pd.DataFrame:
|
|
142
|
+
"""The sample x feature count table the sweep was fitted on."""
|
|
143
|
+
import stripesankey as ss
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
X = ss.load_features(str(base))
|
|
147
|
+
except FileNotFoundError:
|
|
148
|
+
raise FileNotFoundError(
|
|
149
|
+
f"{base}/intermediate/features.csv not found -- it is written by "
|
|
150
|
+
"stripesankey.prepare(). Pass table= explicitly for a sweep fitted elsewhere.")
|
|
151
|
+
X.index = X.index.map(str)
|
|
152
|
+
X.columns = X.columns.map(str)
|
|
153
|
+
return X
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------- #
|
|
157
|
+
# taxonomic levels and names
|
|
158
|
+
# --------------------------------------------------------------------------- #
|
|
159
|
+
def _rank(name) -> str:
|
|
160
|
+
"""A level name or annotation column -> its rank ('genus_ID' -> 'ASV', 'genus' -> 'Genus')."""
|
|
161
|
+
if name is None:
|
|
162
|
+
return "ASV"
|
|
163
|
+
s = str(name)
|
|
164
|
+
low = s.lower()
|
|
165
|
+
if low in _ASV_COLUMNS:
|
|
166
|
+
return "ASV"
|
|
167
|
+
if low == "genus_based":
|
|
168
|
+
return "Genus"
|
|
169
|
+
for r in _RANKS:
|
|
170
|
+
if low == r.lower():
|
|
171
|
+
return r
|
|
172
|
+
return s
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _finer(a: str, b: str) -> bool:
|
|
176
|
+
"""Is rank ``a`` strictly finer than rank ``b`` (unknown ranks never are)?"""
|
|
177
|
+
return a in _RANKS and b in _RANKS and _RANKS.index(a) < _RANKS.index(b)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def fit_level(base) -> str:
|
|
181
|
+
"""The rank the model was fitted on, from ``manifest.json`` ('ASV' for ``level=None``)."""
|
|
182
|
+
import stripesankey as ss
|
|
183
|
+
|
|
184
|
+
m = _manifest(base)
|
|
185
|
+
if m:
|
|
186
|
+
return _rank(m.get("feature_column"))
|
|
187
|
+
try:
|
|
188
|
+
return _rank(ss.load_run_info(str(base)).get("level"))
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
return "ASV"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _display_level(requested, fit_rank: str) -> str:
|
|
194
|
+
"""The level a figure names: the request, or the default for this fit."""
|
|
195
|
+
if requested is None:
|
|
196
|
+
return "Genus" if fit_rank in ("ASV", "Species") else fit_rank
|
|
197
|
+
target = _rank(requested)
|
|
198
|
+
if _finer(target, fit_rank):
|
|
199
|
+
warnings.warn(f"feature_level={requested!r} is finer than the {fit_rank!r} level "
|
|
200
|
+
f"the model was fitted on; showing {fit_rank!r}.", stacklevel=4)
|
|
201
|
+
return fit_rank
|
|
202
|
+
return target
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _column_for(rank: str, columns) -> str | None:
|
|
206
|
+
"""The annotation column holding ``rank`` (case-insensitive)."""
|
|
207
|
+
lower = {str(c).lower(): c for c in columns}
|
|
208
|
+
if rank == "ASV":
|
|
209
|
+
return lower.get("genus_id") or lower.get("randomid") or lower.get("asv")
|
|
210
|
+
return lower.get(rank.lower()) or (lower.get("genus_based") if rank == "Genus" else None)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _display_name(label) -> str:
|
|
214
|
+
"""'g__Prevotella' -> 'Prevotella' (the level is named by the figure, not the label)."""
|
|
215
|
+
return _RANK_PREFIX.sub("", str(label)).strip()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_unclassified(label) -> bool:
|
|
219
|
+
"""Missing, a bare rank prefix ('g__'), 'Unclassified...' (case-sensitive), or
|
|
220
|
+
the marker prepare() writes for a missing annotation ('unassigned_<level>')."""
|
|
221
|
+
if label is None:
|
|
222
|
+
return True
|
|
223
|
+
s = str(label)
|
|
224
|
+
if s.strip().lower() in ("", "nan", "na", "none") or s.startswith("unassigned_"):
|
|
225
|
+
return True
|
|
226
|
+
name = _display_name(s)
|
|
227
|
+
return name == "" or name.startswith("Unclassified")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _labels_at(base, level: str, fit_rank: str):
|
|
231
|
+
"""Fitted token -> its label at ``level``, or None when the annotation lacks that level."""
|
|
232
|
+
ann = _annotation(base)
|
|
233
|
+
target = _column_for(level, ann.columns) if ann is not None else None
|
|
234
|
+
if target is None:
|
|
235
|
+
warnings.warn(f"feature_level={level!r} is not a column of feature_annotation.csv; "
|
|
236
|
+
f"showing the {fit_rank!r} level the model was fitted on.", stacklevel=4)
|
|
237
|
+
return None
|
|
238
|
+
token_col = _manifest(base).get("feature_column")
|
|
239
|
+
if token_col and token_col in ann.columns: # tokens = values of a rank column
|
|
240
|
+
sub = ann[[token_col, target]].dropna(subset=[token_col])
|
|
241
|
+
return sub.groupby(sub[token_col].astype(str))[target].first()
|
|
242
|
+
return ann[target] # tokens = feature ids (ASV fit)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def mc_feature_table(base, k, feature_level=None, *, show_unclassified: bool = True):
|
|
246
|
+
"""What every MC at ``k`` is made of, at one taxonomic level.
|
|
247
|
+
|
|
248
|
+
Returns ``(table, level)``: ``table`` is MC x taxon (index ``MC0 ..``),
|
|
249
|
+
each row the MC's own probability distribution over the fitted features
|
|
250
|
+
(H, row-normalised), summed into ``level`` when that level is coarser than
|
|
251
|
+
the fit. Taxon names lose their rank prefix (``g__``). With
|
|
252
|
+
``show_unclassified=False`` the unclassified taxa are dropped; the
|
|
253
|
+
remaining probabilities are not renormalised.
|
|
254
|
+
"""
|
|
255
|
+
import stripesankey as ss
|
|
256
|
+
|
|
257
|
+
k = _check_k(base, k)
|
|
258
|
+
if not os.path.exists(os.path.join(str(base), "results", "H", f"H_{k}.csv")):
|
|
259
|
+
raise FileNotFoundError(
|
|
260
|
+
f"{base}/results/H/H_{k}.csv not found. MC features need H; a sweep "
|
|
261
|
+
"fitted elsewhere and saved as W only has none.")
|
|
262
|
+
H = ss.load_H(str(base), k)
|
|
263
|
+
P = H.div(H.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0)
|
|
264
|
+
P.index = [f"MC{_mc_index(n, i)}" for i, n in enumerate(P.index)]
|
|
265
|
+
P.columns = P.columns.map(str)
|
|
266
|
+
|
|
267
|
+
fit = fit_level(base)
|
|
268
|
+
level = _display_level(feature_level, fit)
|
|
269
|
+
if level != fit:
|
|
270
|
+
labels = _labels_at(base, level, fit)
|
|
271
|
+
if labels is None:
|
|
272
|
+
level = fit
|
|
273
|
+
else:
|
|
274
|
+
names = []
|
|
275
|
+
for t in P.columns:
|
|
276
|
+
v = labels.get(t, None)
|
|
277
|
+
names.append(str(v) if v is not None and pd.notna(v) else f"unassigned_{level}")
|
|
278
|
+
P = P.T.groupby(pd.Index(names), sort=False).sum().T
|
|
279
|
+
|
|
280
|
+
shown = [_display_name(c) or f"unassigned_{level}" for c in P.columns]
|
|
281
|
+
if len(set(shown)) < len(shown):
|
|
282
|
+
P = P.T.groupby(pd.Index(shown), sort=False).sum().T
|
|
283
|
+
else:
|
|
284
|
+
P.columns = shown
|
|
285
|
+
if not show_unclassified:
|
|
286
|
+
P = P.loc[:, [not _is_unclassified(c) for c in P.columns]]
|
|
287
|
+
P.index.name = "MC"
|
|
288
|
+
P.columns.name = level
|
|
289
|
+
return P, level
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _top_rows(row: pd.Series, top_n: int, show_unclassified: bool) -> list:
|
|
293
|
+
"""The best ``top_n`` taxa of one MC, as ``[[name, prob], ...]``."""
|
|
294
|
+
out = []
|
|
295
|
+
for name, p in row[row > 0].sort_values(ascending=False).items():
|
|
296
|
+
if not show_unclassified and _is_unclassified(name):
|
|
297
|
+
continue
|
|
298
|
+
out.append([str(name), round(float(p), 5)])
|
|
299
|
+
if len(out) >= top_n:
|
|
300
|
+
break
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def taxonomy_for_features(base) -> pd.DataFrame | None:
|
|
305
|
+
"""The feature annotation, keyed by the features that were fitted.
|
|
306
|
+
|
|
307
|
+
For an ASV fit that is ``feature_annotation.csv`` itself. For a fit
|
|
308
|
+
collapsed to a rank (say Genus) the fitted features are genus names, so the
|
|
309
|
+
table is re-keyed by them and keeps only the columns that hold one value per
|
|
310
|
+
genus -- the ranks at and above it -- plus the rank itself.
|
|
311
|
+
"""
|
|
312
|
+
ann = _annotation(base)
|
|
313
|
+
if ann is None:
|
|
314
|
+
return None
|
|
315
|
+
token_col = _manifest(base).get("feature_column")
|
|
316
|
+
if not token_col or token_col not in ann.columns:
|
|
317
|
+
return ann
|
|
318
|
+
sub = ann.dropna(subset=[token_col]).copy()
|
|
319
|
+
key = sub[token_col].astype(str)
|
|
320
|
+
grouped = sub.groupby(key)
|
|
321
|
+
keep = [c for c in sub.columns
|
|
322
|
+
if c != token_col and int(grouped[c].nunique(dropna=True).max() or 0) <= 1]
|
|
323
|
+
tax = grouped[keep].first() if keep else pd.DataFrame(index=sorted(key.unique()))
|
|
324
|
+
tax[token_col] = tax.index
|
|
325
|
+
tax.index.name = token_col
|
|
326
|
+
return tax
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --------------------------------------------------------------------------- #
|
|
330
|
+
# MC selectors
|
|
331
|
+
# --------------------------------------------------------------------------- #
|
|
332
|
+
def parse_mc(selector) -> tuple[int, int]:
|
|
333
|
+
"""``'K5MC2'`` (also ``'K5_MC2'``, ``'k5 mc2'``) -> ``(5, 2)``."""
|
|
334
|
+
m = _MC_SELECTOR.match(selector) if isinstance(selector, str) else None
|
|
335
|
+
if not m:
|
|
336
|
+
raise ValueError(f"could not parse {selector!r}; expected something like 'K5MC2' "
|
|
337
|
+
"(K = the model, MC = the 0-based component index)")
|
|
338
|
+
return int(m.group(1)), int(m.group(2))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def dominant_samples(base, k, mc, high_threshold: float = 0.5) -> list[str]:
|
|
342
|
+
"""Samples dominated by one MC: ``argmax(W) == mc`` and that probability ``>= high_threshold``.
|
|
343
|
+
|
|
344
|
+
The rule metasankey draws its nodes with, so a selector picks what the
|
|
345
|
+
figure shows.
|
|
346
|
+
"""
|
|
347
|
+
W = load_mc(base, k)
|
|
348
|
+
if not 0 <= int(mc) < W.shape[1]:
|
|
349
|
+
raise KeyError(f"MC{mc} is out of range for k={k}; valid: MC0..MC{W.shape[1] - 1}")
|
|
350
|
+
arr = W.to_numpy(dtype=float)
|
|
351
|
+
best, top = arr.argmax(axis=1), arr.max(axis=1)
|
|
352
|
+
return [s for s, b, t in zip(W.index, best, top) if b == int(mc) and t >= high_threshold]
|