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.
@@ -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
+ ]
@@ -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]