bsvae 0.4.2__tar.gz → 0.4.4__tar.gz
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.
- {bsvae-0.4.2 → bsvae-0.4.4}/PKG-INFO +1 -1
- {bsvae-0.4.2 → bsvae-0.4.4}/pyproject.toml +1 -1
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/harness.py +60 -8
- bsvae-0.4.4/src/bsvae/benchmark/kselect.py +125 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/runners.py +12 -1
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/wgcna.R +34 -4
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/benchmark.py +17 -2
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/targets.py +4 -1
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/isoform.py +91 -6
- {bsvae-0.4.2 → bsvae-0.4.4}/LICENSE +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/README.md +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/__init__.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/__init__.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/acorde.R +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/edges.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/methods.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/twn.R +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/__init__.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/_common.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/networks.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/simulate.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/sweep_k.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/train.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/latent/__init__.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/latent/latent_analysis.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/latent/latent_export.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/__init__.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/decoder.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/encoder.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/gmm_prior.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/gmvae.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/losses.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/vae.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/__init__.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/extract_networks.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/module_extraction.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/postprocess.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/quality.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/utils.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/__init__.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/generate.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/isoform.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/metrics.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/scenario.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/__init__.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/datasets.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/helpers.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/hierarchy.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/modelIO.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/preprocess.py +0 -0
- {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/training.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "bsvae"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.4" # dynamically resolved by poetry-dynamic-versioning
|
|
4
4
|
description = "Gaussian Mixture VAE for multi-modal biological module discovery in omics data."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = ["Kynon J Benjamin <kj.benjamin90@gmail.com>"]
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
13
|
import logging
|
|
14
|
+
import re
|
|
14
15
|
import time
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import Dict, Iterable, List, Optional, Sequence
|
|
@@ -35,15 +36,28 @@ logger = logging.getLogger(__name__)
|
|
|
35
36
|
|
|
36
37
|
METHODS = ("bsvae", "wgcna")
|
|
37
38
|
KNOWN_METHODS = ("bsvae", "bsvae_raw", "bsvae_coherent", "bsvae_hier", "bsvae_consensus", "bsvae_isoform", "bsvae_regressed",
|
|
39
|
+
"bsvae_elbo_k", "bsvae_stability_k",
|
|
38
40
|
"wgcna", "wgcna_raw", "wgcna_coherent", "wgcna_signed", "wgcna_unsigned", "wgcna_regressed",
|
|
39
41
|
"usage_network", "ica", "nmf", "acorde", "twn")
|
|
42
|
+
# ``bsvae_elbo_k`` / ``bsvae_stability_k``: the ``bsvae`` method with K chosen from the data (kselect.py);
|
|
43
|
+
# ``wgcna_k<N>``: WGCNA at a forced resolution (spectral clustering of the TOM into N clusters), any N.
|
|
44
|
+
_WGCNA_K = re.compile(r"^wgcna_k(\d+)$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_known_method(method: str) -> bool:
|
|
48
|
+
return method in KNOWN_METHODS or bool(_WGCNA_K.match(method))
|
|
40
49
|
# Gene coherence is the default annotation policy whenever the scenario has transcript structure:
|
|
41
50
|
# ``bsvae`` / ``wgcna`` apply it, ``bsvae_raw`` / ``wgcna_raw`` do not, ``*_coherent`` are explicit aliases.
|
|
42
51
|
COHERENT_DEFAULT = {"bsvae": True, "bsvae_raw": False, "bsvae_coherent": True, "bsvae_regressed": True,
|
|
52
|
+
"bsvae_elbo_k": True, "bsvae_stability_k": True,
|
|
43
53
|
"wgcna": True, "wgcna_raw": False, "wgcna_coherent": True, "wgcna_signed": True,
|
|
44
54
|
"wgcna_unsigned": True, "wgcna_regressed": True}
|
|
45
55
|
|
|
46
56
|
|
|
57
|
+
def _coherent(method: str) -> bool:
|
|
58
|
+
return COHERENT_DEFAULT.get(method, bool(_WGCNA_K.match(method)))
|
|
59
|
+
|
|
60
|
+
|
|
47
61
|
def _apply_coherence(result: MethodResult, expr: pd.DataFrame, truth: Dict, post_cfg: Optional[Dict], out_dir: Path) -> MethodResult:
|
|
48
62
|
"""Gene-coherence annotation policy on a label result (raw labels kept in modules_raw.csv)."""
|
|
49
63
|
from bsvae.networks.postprocess import enforce_gene_coherence
|
|
@@ -204,9 +218,9 @@ def _done_keys(path: Path) -> set:
|
|
|
204
218
|
|
|
205
219
|
|
|
206
220
|
def _check_k_mode(k_mode: str) -> None:
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
221
|
+
# oracle: the planted module count; elbo / stability: one K sweep per dataset (kselect.py).
|
|
222
|
+
from bsvae.benchmark.kselect import check_k_mode
|
|
223
|
+
check_k_mode(k_mode)
|
|
210
224
|
|
|
211
225
|
|
|
212
226
|
def run_one(
|
|
@@ -222,8 +236,12 @@ def run_one(
|
|
|
222
236
|
device=None,
|
|
223
237
|
over_cluster_factor: Optional[float] = None,
|
|
224
238
|
consensus_runs: int = 3,
|
|
239
|
+
method_timeout: Optional[int] = None,
|
|
225
240
|
) -> tuple[MethodResult, Dict]:
|
|
241
|
+
"""``method_timeout``: seconds allowed to an external method process (acorde, TWN) before the row is
|
|
242
|
+
recorded as ``failed``; None keeps each runner's default (7,200 s)."""
|
|
226
243
|
_check_k_mode(k_mode)
|
|
244
|
+
tmo = {"timeout_s": int(method_timeout)} if method_timeout else {}
|
|
227
245
|
truth = load_truth(run_dir)
|
|
228
246
|
expr = pd.read_csv(truth["expr_path"], sep="\t", index_col=0)
|
|
229
247
|
expr.index = expr.index.astype(str)
|
|
@@ -239,9 +257,33 @@ def run_one(
|
|
|
239
257
|
method_base = method[: -len("_raw")]
|
|
240
258
|
else:
|
|
241
259
|
method_base = method
|
|
260
|
+
from bsvae.benchmark.kselect import K_MODE_METHODS
|
|
261
|
+
fixed_k: Optional[int] = None
|
|
262
|
+
m_k = _WGCNA_K.match(method)
|
|
263
|
+
if m_k:
|
|
264
|
+
method_base, fixed_k = "wgcna", int(m_k.group(1))
|
|
265
|
+
elif method in K_MODE_METHODS:
|
|
266
|
+
method_base, k_mode = "bsvae", K_MODE_METHODS[method]
|
|
242
267
|
out_dir = run_dir / "methods" / method / f"seed_{seed}"
|
|
243
268
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
244
269
|
n_modules = int(truth["metadata"]["summary"].get("n_modules", truth["metadata"]["parameters"].get("n_modules", 20)))
|
|
270
|
+
k_select: Dict = {}
|
|
271
|
+
if method_base in ("bsvae", "bsvae_coherent") and k_mode != "oracle":
|
|
272
|
+
from bsvae.benchmark.kselect import (K_GRID_DEFAULT, STABILITY_REPS_DEFAULT, SWEEP_EPOCHS_DEFAULT,
|
|
273
|
+
run_sweep, select_k)
|
|
274
|
+
allcfg = dict(bsvae_cfg or {})
|
|
275
|
+
tx_path = run_dir / "truth" / "tx2gene.tsv"
|
|
276
|
+
table = run_sweep(
|
|
277
|
+
run_dir, truth["expr_path"], tx_path if tx_path.exists() else None,
|
|
278
|
+
train_cfg={k: v for k, v in allcfg.items() if not k.startswith("_")},
|
|
279
|
+
k_grid=allcfg.get("_k_grid") or K_GRID_DEFAULT,
|
|
280
|
+
sweep_epochs=allcfg.get("_sweep_epochs") or SWEEP_EPOCHS_DEFAULT,
|
|
281
|
+
stability_reps=allcfg.get("_stability_reps") or STABILITY_REPS_DEFAULT,
|
|
282
|
+
seed=seed, no_cuda=bool(allcfg.get("no_cuda", False)),
|
|
283
|
+
)
|
|
284
|
+
k_sel, info = select_k(table, k_mode)
|
|
285
|
+
k_select = {"k_mode": k_mode, "n_modules_oracle": n_modules, "selected_k": int(k_sel), **info}
|
|
286
|
+
n_modules = int(k_sel)
|
|
245
287
|
|
|
246
288
|
if method_base in ("bsvae", "bsvae_coherent"):
|
|
247
289
|
cfg = dict(bsvae_cfg or {})
|
|
@@ -333,13 +375,13 @@ def run_one(
|
|
|
333
375
|
min_module_size=(post_cfg or {}).get("min_module_size", 10))
|
|
334
376
|
elif method == "acorde":
|
|
335
377
|
from bsvae.benchmark.methods import run_acorde
|
|
336
|
-
result = run_acorde(expr, workdir=out_dir, seed=seed)
|
|
378
|
+
result = run_acorde(expr, workdir=out_dir, seed=seed, **tmo)
|
|
337
379
|
elif method == "twn":
|
|
338
380
|
from bsvae.benchmark.methods import run_twn
|
|
339
381
|
if "tx2gene" not in truth:
|
|
340
382
|
result = MethodResult("twn", "skipped", message="scenario has no transcript structure")
|
|
341
383
|
else:
|
|
342
|
-
result = run_twn(expr, truth["tx2gene"], workdir=out_dir)
|
|
384
|
+
result = run_twn(expr, truth["tx2gene"], workdir=out_dir, **tmo)
|
|
343
385
|
elif method_base in ("wgcna", "wgcna_unsigned", "wgcna_signed", "wgcna_coherent"):
|
|
344
386
|
cfg = dict(WGCNA_DEFAULTS)
|
|
345
387
|
cfg.update(wgcna_cfg or {})
|
|
@@ -347,14 +389,22 @@ def run_one(
|
|
|
347
389
|
cfg["network_type"] = "unsigned"
|
|
348
390
|
elif method_base == "wgcna_signed":
|
|
349
391
|
cfg["network_type"] = "signed"
|
|
392
|
+
if fixed_k is not None:
|
|
393
|
+
cfg["fixed_k"] = fixed_k
|
|
350
394
|
result = run_wgcna(expr, workdir=out_dir, seed=seed, **cfg)
|
|
351
395
|
else:
|
|
352
396
|
raise ValueError(f"Unknown method: {method}")
|
|
353
397
|
|
|
354
398
|
result.method = method
|
|
355
|
-
if
|
|
399
|
+
if k_select:
|
|
400
|
+
result.params = {**result.params, **k_select}
|
|
401
|
+
if _coherent(method):
|
|
356
402
|
result = _apply_coherence(result, expr, truth, post_cfg, out_dir)
|
|
357
403
|
metrics = evaluate_run(truth, result) if result.status == "ok" else {}
|
|
404
|
+
if k_select:
|
|
405
|
+
metrics = {**metrics, "k_mode": k_mode, "selected_k": k_select["selected_k"], "k_elbo": k_select["k_elbo"],
|
|
406
|
+
"k_stability": k_select.get("k_stability"), "stability_ari_at_k": k_select.get("stability_ari_at_k"),
|
|
407
|
+
"val_elbo_range": k_select["val_elbo_range"]}
|
|
358
408
|
if result.labels is not None:
|
|
359
409
|
pd.DataFrame({"feature_id": result.labels.index, "module": result.labels.values}).to_csv(
|
|
360
410
|
out_dir / "modules.csv", index=False,
|
|
@@ -384,6 +434,7 @@ def run_grid(
|
|
|
384
434
|
device=None,
|
|
385
435
|
over_cluster_factor: Optional[float] = None,
|
|
386
436
|
consensus_runs: int = 3,
|
|
437
|
+
method_timeout: Optional[int] = None,
|
|
387
438
|
) -> pd.DataFrame:
|
|
388
439
|
"""Run every (scenario, rep, method, seed) and return the tidy results table."""
|
|
389
440
|
_check_k_mode(k_mode)
|
|
@@ -402,9 +453,9 @@ def run_grid(
|
|
|
402
453
|
n_reps = int(cfg.get("reps", 1) if reps is None else reps)
|
|
403
454
|
seed_list = list(seeds) if seeds is not None else [0]
|
|
404
455
|
methods = [m for m in methods]
|
|
405
|
-
unknown = [m for m in methods if
|
|
456
|
+
unknown = [m for m in methods if not is_known_method(m)]
|
|
406
457
|
if unknown:
|
|
407
|
-
raise ValueError(f"Unknown method(s): {unknown}; choose from {KNOWN_METHODS}")
|
|
458
|
+
raise ValueError(f"Unknown method(s): {unknown}; choose from {KNOWN_METHODS} or wgcna_k<N>")
|
|
408
459
|
if any(m.startswith("wgcna") for m in methods) and not wgcna_available():
|
|
409
460
|
logger.warning("WGCNA (Rscript + WGCNA package) not available; its rows will be marked skipped")
|
|
410
461
|
|
|
@@ -427,6 +478,7 @@ def run_grid(
|
|
|
427
478
|
run_dir, method, seed, k_mode=k_mode, bsvae_cfg=bsvae_cfg,
|
|
428
479
|
wgcna_cfg=wgcna_cfg, post_cfg=post_cfg, postprocess=postprocess, device=device,
|
|
429
480
|
over_cluster_factor=over_cluster_factor, consensus_runs=consensus_runs,
|
|
481
|
+
method_timeout=method_timeout,
|
|
430
482
|
)
|
|
431
483
|
row = {
|
|
432
484
|
"scenario_id": sid, "rep": rep, "method": method, "seed": seed,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data-driven K for the benchmark harness (``k_mode`` ``elbo`` or ``stability``).
|
|
3
|
+
|
|
4
|
+
The oracle mode hands BSVAE the planted module count. The two data-driven modes run one
|
|
5
|
+
``bsvae-sweep-k`` per dataset, cached under ``<run_dir>/methods/_ksweep`` and shared by every
|
|
6
|
+
method row of that dataset, which scores a K grid by held-out ELBO (gene-grouped validation
|
|
7
|
+
split when the scenario has transcript structure) and, with more than one stability replicate,
|
|
8
|
+
by the mean pairwise ARI of the held-out hard assignments across replicates. ``elbo`` and
|
|
9
|
+
``stability`` read their K from the same table, so the two rows differ only in the final fit.
|
|
10
|
+
The chosen K is then over-clustered like any other expected module count (``over_cluster_k``).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fcntl
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
K_MODES = ("oracle", "elbo", "stability")
|
|
22
|
+
K_MODE_METHODS = {"bsvae_elbo_k": "elbo", "bsvae_stability_k": "stability"}
|
|
23
|
+
K_GRID_DEFAULT: Sequence[int] = (4, 6, 8, 12, 16, 24, 32)
|
|
24
|
+
SWEEP_EPOCHS_DEFAULT = 60
|
|
25
|
+
STABILITY_REPS_DEFAULT = 3
|
|
26
|
+
SWEEP_NAME = "sweep"
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_k_mode(k_mode: str) -> None:
|
|
32
|
+
if k_mode not in K_MODES:
|
|
33
|
+
raise ValueError(f"k_mode must be one of {K_MODES} (got {k_mode!r}); 'oracle' is the planted count")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sweep_results_path(run_dir: str | Path) -> Path:
|
|
37
|
+
return Path(run_dir) / "methods" / "_ksweep" / SWEEP_NAME / "sweep_k" / "sweep_results.csv"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_sweep(
|
|
41
|
+
run_dir: str | Path,
|
|
42
|
+
expr_path: str | Path,
|
|
43
|
+
tx2gene_path: Optional[str | Path] = None,
|
|
44
|
+
*,
|
|
45
|
+
train_cfg: Optional[Dict] = None,
|
|
46
|
+
k_grid: Sequence[int] = K_GRID_DEFAULT,
|
|
47
|
+
sweep_epochs: int = SWEEP_EPOCHS_DEFAULT,
|
|
48
|
+
stability_reps: int = STABILITY_REPS_DEFAULT,
|
|
49
|
+
seed: int = 0,
|
|
50
|
+
no_cuda: bool = False,
|
|
51
|
+
) -> Path:
|
|
52
|
+
"""Run the K sweep for one dataset unless its results table already exists; returns that table.
|
|
53
|
+
|
|
54
|
+
A file lock serialises concurrent rows of the same dataset (the pack runner starts several rows
|
|
55
|
+
at once), so the sweep is trained once and the second row reads the finished table.
|
|
56
|
+
"""
|
|
57
|
+
out = sweep_results_path(run_dir)
|
|
58
|
+
if out.exists():
|
|
59
|
+
return out
|
|
60
|
+
root = out.parents[2] # <run_dir>/methods/_ksweep
|
|
61
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
with open(root / ".lock", "w") as fh:
|
|
63
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
64
|
+
try:
|
|
65
|
+
if not out.exists():
|
|
66
|
+
_run(root, expr_path, tx2gene_path, train_cfg or {}, k_grid, sweep_epochs, stability_reps, seed, no_cuda)
|
|
67
|
+
finally:
|
|
68
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
69
|
+
if not out.exists():
|
|
70
|
+
raise RuntimeError(f"K sweep finished without writing {out}")
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _run(root: Path, expr_path, tx2gene_path, train_cfg: Dict, k_grid, sweep_epochs, stability_reps, seed, no_cuda) -> None:
|
|
75
|
+
from bsvae.benchmark.runners import BSVAE_DEFAULTS
|
|
76
|
+
from bsvae.cli.sweep_k import main as sweep_main, parse_args as sweep_parse_args
|
|
77
|
+
|
|
78
|
+
argv = [SWEEP_NAME, "--dataset", str(expr_path), "--outdir", str(root),
|
|
79
|
+
"--k-grid", ",".join(str(int(k)) for k in k_grid),
|
|
80
|
+
"--sweep-epochs", str(int(sweep_epochs)), "--stability-reps", str(int(stability_reps)),
|
|
81
|
+
"--no-train-final", "--seed", str(int(seed)), "--log-level", "warning"]
|
|
82
|
+
if tx2gene_path is not None:
|
|
83
|
+
argv += ["--tx2gene", str(tx2gene_path)]
|
|
84
|
+
if no_cuda:
|
|
85
|
+
argv += ["--no-cuda"]
|
|
86
|
+
args = sweep_parse_args(argv)
|
|
87
|
+
cfg = dict(BSVAE_DEFAULTS)
|
|
88
|
+
cfg.update({k: v for k, v in train_cfg.items() if v is not None})
|
|
89
|
+
for k in ("epochs", "n_modules", "seed", "no_cuda", "tx2gene", "outdir", "log_level"):
|
|
90
|
+
cfg.pop(k, None)
|
|
91
|
+
unknown = [k for k in cfg if not hasattr(args, k)]
|
|
92
|
+
if unknown:
|
|
93
|
+
raise ValueError(f"Unknown BSVAE option(s) for the K sweep: {unknown}")
|
|
94
|
+
for k, v in cfg.items():
|
|
95
|
+
setattr(args, k, v)
|
|
96
|
+
logger.info("K sweep: grid %s, %d epochs, %d stability reps -> %s", list(k_grid), sweep_epochs, stability_reps, root)
|
|
97
|
+
sweep_main(args)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def select_k(results_csv: str | Path, k_mode: str) -> Tuple[int, Dict]:
|
|
101
|
+
"""K for ``k_mode`` from a sweep table, with the selections of both modes in the returned info."""
|
|
102
|
+
t = pd.read_csv(results_csv)
|
|
103
|
+
if t.empty or "val_elbo_mean" not in t:
|
|
104
|
+
raise ValueError(f"empty or malformed sweep table: {results_csv}")
|
|
105
|
+
t = t.sort_values("k").reset_index(drop=True)
|
|
106
|
+
elbo_row = t.loc[t["val_elbo_mean"].idxmin()]
|
|
107
|
+
info: Dict = {"k_grid": [int(k) for k in t["k"]], "k_elbo": int(elbo_row["k"]),
|
|
108
|
+
"val_elbo_range": float(t["val_elbo_mean"].max() - t["val_elbo_mean"].min())}
|
|
109
|
+
stab = t["stability_ari_mean"] if "stability_ari_mean" in t else pd.Series(dtype=float)
|
|
110
|
+
if stab.notna().any():
|
|
111
|
+
s = t.sort_values(["stability_ari_mean", "val_elbo_mean"], ascending=[False, True])
|
|
112
|
+
info["k_stability"] = int(s["k"].iloc[0])
|
|
113
|
+
info["stability_ari_at_k"] = float(s["stability_ari_mean"].iloc[0])
|
|
114
|
+
if k_mode == "elbo":
|
|
115
|
+
return info["k_elbo"], info
|
|
116
|
+
if k_mode == "stability":
|
|
117
|
+
if "k_stability" not in info:
|
|
118
|
+
raise ValueError("the sweep has no stability column; rerun it with stability_reps > 1")
|
|
119
|
+
return info["k_stability"], info
|
|
120
|
+
check_k_mode(k_mode)
|
|
121
|
+
raise ValueError("select_k is for the data-driven modes; oracle K comes from the scenario")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["K_MODES", "K_MODE_METHODS", "K_GRID_DEFAULT", "SWEEP_EPOCHS_DEFAULT", "STABILITY_REPS_DEFAULT",
|
|
125
|
+
"check_k_mode", "run_sweep", "select_k", "sweep_results_path"]
|
|
@@ -125,12 +125,21 @@ def run_wgcna(
|
|
|
125
125
|
n_threads: int = 1,
|
|
126
126
|
seed: int = 42,
|
|
127
127
|
timeout_s: int = 7200,
|
|
128
|
+
fixed_k: Optional[int] = None,
|
|
128
129
|
) -> MethodResult:
|
|
129
|
-
"""Run WGCNA on a features × samples matrix via ``Rscript``.
|
|
130
|
+
"""Run WGCNA on a features × samples matrix via ``Rscript``.
|
|
131
|
+
|
|
132
|
+
``fixed_k`` replaces the dynamic tree cut by a spectral clustering of the TOM into that many
|
|
133
|
+
clusters (normalised Laplacian, k-means; no merge step, no size floor): WGCNA's network at a
|
|
134
|
+
forced resolution, single-block path only. Ward on the TOM dissimilarity (the first
|
|
135
|
+
implementation) leaves one giant cluster on gene-level data and PAM peels singletons on isoform
|
|
136
|
+
data, so neither serves as a resolution comparator across the benchmark families.
|
|
137
|
+
"""
|
|
130
138
|
params = dict(
|
|
131
139
|
network_type=network_type, soft_power=soft_power, min_module_size=min_module_size,
|
|
132
140
|
deep_split=deep_split, merge_cut_height=merge_cut_height,
|
|
133
141
|
blockwise_threshold=blockwise_threshold, max_block_size=max_block_size, seed=seed,
|
|
142
|
+
fixed_k=fixed_k,
|
|
134
143
|
)
|
|
135
144
|
if not wgcna_available():
|
|
136
145
|
logger.warning("Rscript/WGCNA not available; skipping WGCNA")
|
|
@@ -155,6 +164,8 @@ def run_wgcna(
|
|
|
155
164
|
]
|
|
156
165
|
if soft_power is not None:
|
|
157
166
|
cmd += ["--soft-power", str(int(soft_power))]
|
|
167
|
+
if fixed_k is not None:
|
|
168
|
+
cmd += ["--fixed-k", str(int(fixed_k))]
|
|
158
169
|
|
|
159
170
|
t0 = time.perf_counter()
|
|
160
171
|
try:
|
|
@@ -5,17 +5,38 @@
|
|
|
5
5
|
## Rscript wgcna.R --expr expr.csv.gz --outdir out [--network-type signed]
|
|
6
6
|
## [--soft-power P] [--min-module-size 10] [--deep-split 2]
|
|
7
7
|
## [--merge-cut-height 0.25] [--blockwise-threshold 20000]
|
|
8
|
-
## [--max-block-size 5000] [--threads 1] [--seed 42]
|
|
8
|
+
## [--max-block-size 5000] [--threads 1] [--seed 42] [--fixed-k 0]
|
|
9
|
+
##
|
|
10
|
+
## --fixed-k K > 0 replaces the dynamic tree cut by a spectral clustering of the TOM into K clusters
|
|
11
|
+
## (normalised Laplacian D^-1/2 TOM D^-1/2, its K leading eigenvectors row-normalised, k-means with 20
|
|
12
|
+
## restarts; no merge step, no size floor): WGCNA's network at a forced resolution. The other fixed-K
|
|
13
|
+
## rules on the same network fail on one of the benchmark families: cutting the average-linkage tree
|
|
14
|
+
## or PAM on the TOM dissimilarity peel off singletons (PAM recovers the gene-level modules but gives
|
|
15
|
+
## singletons on isoform data and whenever K exceeds the number of real clusters), and Ward (ward.D2) on
|
|
16
|
+
## the TOM dissimilarity leaves one giant cluster on gene-level data (ARI 0.08 against 0.62 for the
|
|
17
|
+
## dynamic cut at the planted K). Spectral clustering gives K balanced clusters on both.
|
|
9
18
|
##
|
|
10
19
|
## Writes modules.csv (feature_id, module, module_label; grey -> -1),
|
|
11
20
|
## eigengenes.csv (samples x modules) and params.json.
|
|
12
21
|
|
|
13
22
|
suppressPackageStartupMessages(library(WGCNA))
|
|
14
23
|
|
|
24
|
+
## Spectral clustering of a similarity matrix into exactly K clusters (Ng-Jordan-Weiss): the K leading
|
|
25
|
+
## eigenvectors of D^-1/2 S D^-1/2, rows normalised to unit length, k-means with 20 restarts.
|
|
26
|
+
spectral_fixed_k <- function(S, K, seed) {
|
|
27
|
+
d <- pmax(rowSums(S), .Machine$double.eps)
|
|
28
|
+
L <- sweep(sweep(S, 1, sqrt(d), "/"), 2, sqrt(d), "/")
|
|
29
|
+
ev <- eigen(L, symmetric = TRUE)
|
|
30
|
+
U <- ev$vectors[, seq_len(K), drop = FALSE]
|
|
31
|
+
U <- U / pmax(sqrt(rowSums(U^2)), .Machine$double.eps)
|
|
32
|
+
set.seed(seed)
|
|
33
|
+
as.integer(kmeans(U, centers = K, nstart = 20, iter.max = 200)$cluster)
|
|
34
|
+
}
|
|
35
|
+
|
|
15
36
|
args <- commandArgs(trailingOnly = TRUE)
|
|
16
37
|
opt <- list(expr = NULL, outdir = NULL, `network-type` = "signed", `soft-power` = NA,
|
|
17
38
|
`min-module-size` = 10, `deep-split` = 2, `merge-cut-height` = 0.25,
|
|
18
|
-
`blockwise-threshold` = 20000, `max-block-size` = 5000, threads = 1, seed = 42)
|
|
39
|
+
`blockwise-threshold` = 20000, `max-block-size` = 5000, threads = 1, seed = 42, `fixed-k` = 0)
|
|
19
40
|
i <- 1
|
|
20
41
|
while (i <= length(args)) {
|
|
21
42
|
key <- sub("^--", "", args[i])
|
|
@@ -70,6 +91,9 @@ if (is.na(soft_power)) {
|
|
|
70
91
|
min_size <- int(opt$`min-module-size`)
|
|
71
92
|
deep_split <- int(opt$`deep-split`)
|
|
72
93
|
cut_height <- num(opt$`merge-cut-height`)
|
|
94
|
+
fixed_k <- int(opt$`fixed-k`)
|
|
95
|
+
if (is.na(fixed_k)) fixed_k <- 0L
|
|
96
|
+
if (fixed_k > 0 && n_feat > int(opt$`blockwise-threshold`)) stop("--fixed-k needs the single-block path (n_feat <= blockwise-threshold)")
|
|
73
97
|
|
|
74
98
|
if (n_feat > int(opt$`blockwise-threshold`)) {
|
|
75
99
|
log_msg("Blockwise WGCNA (maxBlockSize=%d)", int(opt$`max-block-size`))
|
|
@@ -87,6 +111,11 @@ if (n_feat > int(opt$`blockwise-threshold`)) {
|
|
|
87
111
|
TOM <- TOMsimilarity(adj, TOMType = tom_type, verbose = 0)
|
|
88
112
|
dissTOM <- 1 - TOM
|
|
89
113
|
tree <- hclust(as.dist(dissTOM), method = "average")
|
|
114
|
+
if (fixed_k > 0) {
|
|
115
|
+
log_msg("Spectral clustering of the TOM into %d clusters (normalised Laplacian, k-means)", fixed_k)
|
|
116
|
+
labels <- spectral_fixed_k(TOM, min(fixed_k, n_feat), int(opt$seed))
|
|
117
|
+
MEs <- moduleEigengenes(datExpr, colors = labels)$eigengenes
|
|
118
|
+
} else {
|
|
90
119
|
dyn <- cutreeDynamic(dendro = tree, distM = dissTOM, deepSplit = deep_split,
|
|
91
120
|
pamRespectsDendro = FALSE, minClusterSize = min_size, verbose = 0)
|
|
92
121
|
if (length(unique(dyn[dyn != 0])) >= 2) {
|
|
@@ -97,6 +126,7 @@ if (n_feat > int(opt$`blockwise-threshold`)) {
|
|
|
97
126
|
labels <- dyn
|
|
98
127
|
MEs <- moduleEigengenes(datExpr, colors = labels)$eigengenes
|
|
99
128
|
}
|
|
129
|
+
}
|
|
100
130
|
}
|
|
101
131
|
|
|
102
132
|
## Relabel to 0..M-1 with grey (0) -> -1
|
|
@@ -121,8 +151,8 @@ colnames(MEs_out) <- paste0("ME", as.integer(relabel[me_names[keep]]))
|
|
|
121
151
|
MEs_out <- cbind(data.frame(sample_id = rownames(datExpr), stringsAsFactors = FALSE), MEs_out)
|
|
122
152
|
write.csv(MEs_out, file.path(opt$outdir, "eigengenes.csv"), row.names = FALSE, quote = FALSE)
|
|
123
153
|
|
|
124
|
-
params <- sprintf('{"soft_power": %d, "network_type": "%s", "min_module_size": %d, "deep_split": %d, "merge_cut_height": %s, "n_modules": %d, "n_grey": %d, "blockwise": %s}',
|
|
125
|
-
as.integer(soft_power), network_type, min_size, deep_split, format(cut_height),
|
|
154
|
+
params <- sprintf('{"soft_power": %d, "network_type": "%s", "min_module_size": %d, "deep_split": %d, "merge_cut_height": %s, "fixed_k": %d, "n_modules": %d, "n_grey": %d, "blockwise": %s}',
|
|
155
|
+
as.integer(soft_power), network_type, min_size, deep_split, format(cut_height), fixed_k,
|
|
126
156
|
length(uniq), sum(all_module < 0), tolower(as.character(n_feat > int(opt$`blockwise-threshold`))))
|
|
127
157
|
writeLines(params, file.path(opt$outdir, "params.json"))
|
|
128
158
|
log_msg("Done: %d modules, %d grey", length(uniq), sum(all_module < 0))
|
|
@@ -31,8 +31,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
31
31
|
help="Comma list of replicate indices to run (e.g. one per SLURM task); overrides --reps.")
|
|
32
32
|
r.add_argument("--seeds", default="0", help="Comma list of method seeds (default: 0).")
|
|
33
33
|
r.add_argument("--scenario-ids", default=None, help="Comma list to restrict scenarios.")
|
|
34
|
-
r.add_argument("--k-mode", choices=["oracle"], default="oracle",
|
|
35
|
-
help="oracle: the
|
|
34
|
+
r.add_argument("--k-mode", choices=["oracle", "elbo", "stability"], default="oracle",
|
|
35
|
+
help="oracle: the planted module count (times --over-cluster-factor); elbo / stability: K from one "
|
|
36
|
+
"held-out sweep per dataset (bsvae-sweep-k), selected by validation ELBO or by assignment "
|
|
37
|
+
"stability across replicates. The methods bsvae_elbo_k / bsvae_stability_k force their mode.")
|
|
38
|
+
r.add_argument("--k-grid", default=None, help="K grid of the sweep (comma list; default 4,6,8,12,16,24,32).")
|
|
39
|
+
r.add_argument("--sweep-epochs", type=int, default=None, help="Epochs per sweep fit (default 60).")
|
|
40
|
+
r.add_argument("--stability-reps", type=int, default=None,
|
|
41
|
+
help="Training replicates per K in the sweep (default 3; 1 gives ELBO selection only).")
|
|
36
42
|
r.add_argument("--no-resume", dest="resume", action="store_false", default=True)
|
|
37
43
|
r.add_argument("--no-cuda", action="store_true", default=False)
|
|
38
44
|
r.add_argument("--log-level", default="info")
|
|
@@ -65,6 +71,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
65
71
|
r.add_argument("--wgcna-min-module-size", type=int, default=None)
|
|
66
72
|
r.add_argument("--wgcna-merge-cut-height", type=float, default=None)
|
|
67
73
|
r.add_argument("--wgcna-threads", type=int, default=None)
|
|
74
|
+
r.add_argument("--method-timeout", type=int, default=None,
|
|
75
|
+
help="Seconds allowed to an external method process (acorde, twn) before the row fails (default 7200).")
|
|
68
76
|
|
|
69
77
|
s = subs.add_parser("summarize", help="Mean ± sd per scenario and method.")
|
|
70
78
|
s.add_argument("--results", required=True, help="results.tsv written by `run`.")
|
|
@@ -87,6 +95,12 @@ def handle_run(args, logger):
|
|
|
87
95
|
"bal_type", "normalize_input"])
|
|
88
96
|
if args.no_cuda:
|
|
89
97
|
bsvae_cfg["no_cuda"] = True
|
|
98
|
+
if args.k_grid:
|
|
99
|
+
bsvae_cfg["_k_grid"] = [int(x) for x in args.k_grid.split(",") if x.strip()]
|
|
100
|
+
if args.sweep_epochs is not None:
|
|
101
|
+
bsvae_cfg["_sweep_epochs"] = args.sweep_epochs
|
|
102
|
+
if args.stability_reps is not None:
|
|
103
|
+
bsvae_cfg["_stability_reps"] = args.stability_reps
|
|
90
104
|
post_cfg = _opt(args, ["min_gamma", "min_module_size", "merge_cut_height", "kme_reassign"])
|
|
91
105
|
wgcna_cfg = {}
|
|
92
106
|
for cli_name, key in [("wgcna_network_type", "network_type"), ("wgcna_soft_power", "soft_power"),
|
|
@@ -106,6 +120,7 @@ def handle_run(args, logger):
|
|
|
106
120
|
postprocess=not args.no_postprocess, resume=args.resume,
|
|
107
121
|
scenario_ids=[s.strip() for s in args.scenario_ids.split(",")] if args.scenario_ids else None,
|
|
108
122
|
over_cluster_factor=args.over_cluster_factor, consensus_runs=args.consensus_runs,
|
|
123
|
+
method_timeout=args.method_timeout,
|
|
109
124
|
)
|
|
110
125
|
logger.info("Benchmark complete: %d rows → %s/results.tsv", len(df), args.outdir)
|
|
111
126
|
|
|
@@ -676,7 +676,10 @@ def combine_target_tests(
|
|
|
676
676
|
null_like = zmat[(zmat.max(axis=1) < sps.norm.isf(0.05)) | zmat.isna().any(axis=1)]
|
|
677
677
|
src = null_like if len(null_like) >= 20 else zmat
|
|
678
678
|
rho = src.corr(min_periods=5).fillna(0.0).clip(lower=0.0)
|
|
679
|
-
|
|
679
|
+
# write the unit diagonal into a copy: under pandas copy-on-write (the pandas 3 default) .values is read-only
|
|
680
|
+
r = rho.to_numpy(dtype=float, copy=True)
|
|
681
|
+
np.fill_diagonal(r, 1.0)
|
|
682
|
+
rho = pd.DataFrame(r, index=rho.index, columns=rho.columns)
|
|
680
683
|
rows = []
|
|
681
684
|
for tx, g in long.groupby(level=0):
|
|
682
685
|
p = np.clip(g["p_value"].values.astype(float), 1e-300, 1.0)
|
|
@@ -166,6 +166,7 @@ def cross_fit_calls(
|
|
|
166
166
|
min_kme: float = 0.3,
|
|
167
167
|
alpha: Optional[float] = 0.05,
|
|
168
168
|
require_module_agreement: bool = False,
|
|
169
|
+
null: str = "analytic",
|
|
169
170
|
**kwargs,
|
|
170
171
|
) -> pd.DataFrame:
|
|
171
172
|
"""
|
|
@@ -192,7 +193,11 @@ def cross_fit_calls(
|
|
|
192
193
|
one), ``label_discordant`` (the transcript's raw label differs from its
|
|
193
194
|
gene's in any fold), per-fold ``kme_fold<f>`` columns and ``called``.
|
|
194
195
|
``require_module_agreement`` additionally demands ``modules_agree`` for a
|
|
195
|
-
call (an annotation by default).
|
|
196
|
+
call (an annotation by default). ``null="stratum"`` replaces the
|
|
197
|
+
Bonferroni p of the combined statistic with the empirical null fitted per
|
|
198
|
+
selection stratum (:func:`stratum_empirical_null` on |z|; scales in
|
|
199
|
+
``out.attrs["null_scale"]``); the per-fold scans always use the analytic
|
|
200
|
+
form since only their kME enter the combination.
|
|
196
201
|
"""
|
|
197
202
|
from scipy import stats as sps
|
|
198
203
|
|
|
@@ -239,7 +244,13 @@ def cross_fit_calls(
|
|
|
239
244
|
rows = np.arange(len(idx))
|
|
240
245
|
z_best = Zc[rows, best_j]
|
|
241
246
|
n_mod = max(1, len(cols))
|
|
242
|
-
|
|
247
|
+
null_scale: Dict[object, float] = {}
|
|
248
|
+
if null == "analytic":
|
|
249
|
+
p = np.minimum(2.0 * sps.norm.sf(np.abs(z_best)) * n_mod, 1.0)
|
|
250
|
+
elif null == "stratum":
|
|
251
|
+
p, null_scale = stratum_empirical_null(np.abs(z_best), discordant, n_mod)
|
|
252
|
+
else:
|
|
253
|
+
raise ValueError("null must be 'analytic' or 'stratum'")
|
|
243
254
|
q = _bh_adjust(p)
|
|
244
255
|
kme = KW[rows, best_j] / np.maximum(NW[rows, best_j], 1.0)
|
|
245
256
|
agree = np.all(np.vstack(fold_best) == best_j[None, :], axis=0)
|
|
@@ -268,6 +279,7 @@ def cross_fit_calls(
|
|
|
268
279
|
"called": called,
|
|
269
280
|
}, index=idx)
|
|
270
281
|
out.index.name = "feature_id"
|
|
282
|
+
out.attrs["null_scale"] = null_scale
|
|
271
283
|
logger.info("Cross-fitted isoform-specific calls over %d folds: %d of %d transcripts called",
|
|
272
284
|
len(folds), int(called.sum()), len(out))
|
|
273
285
|
return out
|
|
@@ -286,6 +298,59 @@ def _bh_adjust(p: np.ndarray) -> np.ndarray:
|
|
|
286
298
|
return np.minimum(q, 1.0)
|
|
287
299
|
|
|
288
300
|
|
|
301
|
+
def stratum_empirical_null(stat: np.ndarray, strata: np.ndarray, n_modules: "int | np.ndarray",
|
|
302
|
+
min_size: int = 50, levels: Tuple[float, ...] = (0.6, 0.8)) -> Tuple[np.ndarray, Dict[object, float]]:
|
|
303
|
+
"""
|
|
304
|
+
Empirical null of a best-of-``n_modules`` statistic, fitted per selection stratum.
|
|
305
|
+
|
|
306
|
+
``stat`` is the absolute statistic of each transcript's best module (the
|
|
307
|
+
|t| of the best residual kME, or the combined |z| of the cross-fit),
|
|
308
|
+
``strata`` the stratum each transcript was selected in (the callers pass
|
|
309
|
+
``label_discordant``: whether the transcript's own label is the module it
|
|
310
|
+
is tested against). Under the null ``stat / sigma_s`` is taken to be the
|
|
311
|
+
maximum of ``n_modules`` independent |N(0, 1)|, with CDF
|
|
312
|
+
``(2 Phi(x) - 1) ** m``; ``sigma_s`` is fitted per stratum by least squares
|
|
313
|
+
on the ``levels`` quantiles of ``stat`` (default the 60th and 80th
|
|
314
|
+
percentiles). The lower quantiles of a best-of-m statistic over
|
|
315
|
+
correlated modules sit below the independent-module form, so anchoring the
|
|
316
|
+
fit there (25th/50th) shrinks ``sigma_s`` and inflates the tail; anchors
|
|
317
|
+
at the 90th percentile or above absorb real divergence once it exceeds
|
|
318
|
+
about a tenth of a stratum. The 60th/80th pair keeps the concordant
|
|
319
|
+
stratum near the analytic calibration and tolerates up to ~15 % of
|
|
320
|
+
real signal in the upper tail. A stratum with fewer than
|
|
321
|
+
``min_size`` transcripts keeps ``sigma_s = 1``. At ``sigma_s = 1`` the
|
|
322
|
+
p-value is Sidak's ``1 - (1 - p_best) ** m`` rather than the Bonferroni
|
|
323
|
+
``min(1, m p_best)`` of the analytic null.
|
|
324
|
+
|
|
325
|
+
Returns the p-values and the fitted scale per stratum.
|
|
326
|
+
"""
|
|
327
|
+
from scipy import stats as sps
|
|
328
|
+
|
|
329
|
+
a = np.abs(np.asarray(stat, dtype=np.float64))
|
|
330
|
+
strata = np.asarray(strata)
|
|
331
|
+
m = np.maximum(np.broadcast_to(np.asarray(n_modules, dtype=np.float64), a.shape), 1.0)
|
|
332
|
+
levels = np.asarray(levels, dtype=np.float64)
|
|
333
|
+
p = np.ones_like(a)
|
|
334
|
+
scales: Dict[object, float] = {}
|
|
335
|
+
for s in pd.unique(strata):
|
|
336
|
+
idx = np.where(strata == s)[0]
|
|
337
|
+
sigma = 1.0
|
|
338
|
+
if idx.size >= int(min_size):
|
|
339
|
+
ms = float(np.median(m[idx]))
|
|
340
|
+
x = sps.norm.ppf((levels ** (1.0 / ms) + 1.0) / 2.0) # null quantiles at unit scale
|
|
341
|
+
q = np.quantile(a[idx], levels)
|
|
342
|
+
sigma = float((q * x).sum() / (x * x).sum())
|
|
343
|
+
if not np.isfinite(sigma) or sigma <= 0:
|
|
344
|
+
sigma = 1.0
|
|
345
|
+
scales[s.item() if hasattr(s, "item") else s] = sigma
|
|
346
|
+
sf = np.minimum(sps.norm.sf(a[idx] / sigma), 0.5 - 1e-12)
|
|
347
|
+
p[idx] = -np.expm1(m[idx] * np.log1p(-2.0 * sf))
|
|
348
|
+
return np.clip(p, 0.0, 1.0), scales
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
__all__.append("stratum_empirical_null")
|
|
352
|
+
|
|
353
|
+
|
|
289
354
|
def _residualize_eigengenes(eigengenes: pd.DataFrame, column: int) -> pd.DataFrame:
|
|
290
355
|
"""Regress every eigengene on ``eigengenes[:, column]`` and return the residuals."""
|
|
291
356
|
E = eigengenes.values.astype(np.float64)
|
|
@@ -317,6 +382,7 @@ def isoform_specific_calls(
|
|
|
317
382
|
return_kme: bool = False,
|
|
318
383
|
covariates: Optional[pd.DataFrame] = None,
|
|
319
384
|
max_target_corr: Optional[float] = None,
|
|
385
|
+
null: str = "analytic",
|
|
320
386
|
) -> "pd.DataFrame | Tuple[pd.DataFrame, pd.DataFrame]":
|
|
321
387
|
"""
|
|
322
388
|
Call isoforms whose *gene-programme-removed* profile belongs to a module
|
|
@@ -359,6 +425,16 @@ def isoform_specific_calls(
|
|
|
359
425
|
0.3 with the residualised eigengenes keeps precision above the previous
|
|
360
426
|
0.4 default on the isoform simulations.
|
|
361
427
|
|
|
428
|
+
``null`` chooses the reference distribution of the best kME.
|
|
429
|
+
``"analytic"`` (default) is the t-test above, Bonferroni-corrected over
|
|
430
|
+
modules. ``"stratum"`` fits an empirical null per selection stratum
|
|
431
|
+
(``label_discordant`` True / False) with :func:`stratum_empirical_null`:
|
|
432
|
+
the |t| of the best module is scaled by a stratum-specific factor fitted
|
|
433
|
+
on the lower half of the stratum's distribution, so transcripts whose
|
|
434
|
+
own label is the tested module are compared with the distribution that
|
|
435
|
+
selection actually gives them. The fitted scales are returned in
|
|
436
|
+
``out.attrs["null_scale"]``.
|
|
437
|
+
|
|
362
438
|
Every multi-isoform transcript is tested, whatever its own module label:
|
|
363
439
|
the residual test is the primary evidence for isoform-specific
|
|
364
440
|
membership, and ``label_discordant`` (the transcript's own label differs
|
|
@@ -413,7 +489,8 @@ def isoform_specific_calls(
|
|
|
413
489
|
E = _residualize_eigengenes(eigengenes, cols.index(int(gm))) if gm in cols else eigengenes
|
|
414
490
|
kme[rows_idx] = compute_kme(resid.iloc[rows_idx], E, list(resid.index[rows_idx])).values
|
|
415
491
|
else:
|
|
416
|
-
kme
|
|
492
|
+
# a writable copy: the leave-one-out pass below updates kme in place (read-only view under copy-on-write)
|
|
493
|
+
kme = compute_kme(resid, eigengenes, list(resid.index)).to_numpy(dtype=np.float64, copy=True)
|
|
417
494
|
mode = {True: "exact", False: "off"}.get(loo_eigengene, loo_eigengene)
|
|
418
495
|
if mode not in ("exact", "approx", "off"):
|
|
419
496
|
raise ValueError("loo_eigengene must be True/False, 'exact' or 'approx'")
|
|
@@ -488,17 +565,24 @@ def isoform_specific_calls(
|
|
|
488
565
|
n = expr.shape[1]
|
|
489
566
|
r2 = np.minimum(best ** 2, 1.0 - 1e-12)
|
|
490
567
|
tval = np.abs(best) * np.sqrt(max(n - 2, 1) / (1.0 - r2))
|
|
491
|
-
|
|
568
|
+
own = lab.reindex(resid.index).fillna(-1).astype(int).values
|
|
569
|
+
discordant = (own >= 0) & (gm_per_tx >= 0) & (own != gm_per_tx)
|
|
570
|
+
null_scale: Dict[object, float] = {}
|
|
571
|
+
if null == "analytic":
|
|
572
|
+
p = np.minimum(2.0 * sps.t.sf(tval, max(n - 2, 1)) * len(cols), 1.0)
|
|
573
|
+
elif null == "stratum":
|
|
574
|
+
p, null_scale = stratum_empirical_null(tval, discordant, len(cols))
|
|
575
|
+
else:
|
|
576
|
+
raise ValueError("null must be 'analytic' or 'stratum'")
|
|
492
577
|
q = _bh_adjust(p)
|
|
493
578
|
called = np.abs(best) >= float(min_kme)
|
|
494
579
|
if alpha is not None:
|
|
495
580
|
called &= q < float(alpha)
|
|
496
|
-
own = lab.reindex(resid.index).fillna(-1).astype(int).values
|
|
497
581
|
out = pd.DataFrame({
|
|
498
582
|
"gene_id": stats["gene_id"].values,
|
|
499
583
|
"gene_module": gm_per_tx,
|
|
500
584
|
"isoform_module": own,
|
|
501
|
-
"label_discordant":
|
|
585
|
+
"label_discordant": discordant,
|
|
502
586
|
"best_other_module": [cols[j] for j in best_j],
|
|
503
587
|
"kme_residual": best,
|
|
504
588
|
"p_value": p,
|
|
@@ -509,6 +593,7 @@ def isoform_specific_calls(
|
|
|
509
593
|
"called": called,
|
|
510
594
|
}, index=resid.index)
|
|
511
595
|
out.index.name = "feature_id"
|
|
596
|
+
out.attrs["null_scale"] = null_scale
|
|
512
597
|
logger.info("Isoform-specific calls: %d of %d multi-isoform transcripts (kME ≥ %.2f%s)",
|
|
513
598
|
int(out["called"].sum()), len(out), min_kme, f", FDR < {alpha}" if alpha is not None else "")
|
|
514
599
|
if return_kme:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|