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.
Files changed (51) hide show
  1. {bsvae-0.4.2 → bsvae-0.4.4}/PKG-INFO +1 -1
  2. {bsvae-0.4.2 → bsvae-0.4.4}/pyproject.toml +1 -1
  3. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/harness.py +60 -8
  4. bsvae-0.4.4/src/bsvae/benchmark/kselect.py +125 -0
  5. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/runners.py +12 -1
  6. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/wgcna.R +34 -4
  7. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/benchmark.py +17 -2
  8. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/targets.py +4 -1
  9. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/isoform.py +91 -6
  10. {bsvae-0.4.2 → bsvae-0.4.4}/LICENSE +0 -0
  11. {bsvae-0.4.2 → bsvae-0.4.4}/README.md +0 -0
  12. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/__init__.py +0 -0
  13. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/__init__.py +0 -0
  14. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/acorde.R +0 -0
  15. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/edges.py +0 -0
  16. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/methods.py +0 -0
  17. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/benchmark/twn.R +0 -0
  18. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/__init__.py +0 -0
  19. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/_common.py +0 -0
  20. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/networks.py +0 -0
  21. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/simulate.py +0 -0
  22. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/sweep_k.py +0 -0
  23. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/cli/train.py +0 -0
  24. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/latent/__init__.py +0 -0
  25. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/latent/latent_analysis.py +0 -0
  26. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/latent/latent_export.py +0 -0
  27. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/__init__.py +0 -0
  28. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/decoder.py +0 -0
  29. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/encoder.py +0 -0
  30. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/gmm_prior.py +0 -0
  31. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/gmvae.py +0 -0
  32. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/losses.py +0 -0
  33. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/models/vae.py +0 -0
  34. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/__init__.py +0 -0
  35. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/extract_networks.py +0 -0
  36. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/module_extraction.py +0 -0
  37. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/postprocess.py +0 -0
  38. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/quality.py +0 -0
  39. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/networks/utils.py +0 -0
  40. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/__init__.py +0 -0
  41. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/generate.py +0 -0
  42. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/isoform.py +0 -0
  43. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/metrics.py +0 -0
  44. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/simulation/scenario.py +0 -0
  45. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/__init__.py +0 -0
  46. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/datasets.py +0 -0
  47. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/helpers.py +0 -0
  48. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/hierarchy.py +0 -0
  49. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/modelIO.py +0 -0
  50. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/preprocess.py +0 -0
  51. {bsvae-0.4.2 → bsvae-0.4.4}/src/bsvae/utils/training.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bsvae
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Gaussian Mixture VAE for multi-modal biological module discovery in omics data.
5
5
  License: GPL-3.0-only
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bsvae"
3
- version = "0.4.2" # dynamically resolved by poetry-dynamic-versioning
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
- # K comes from the simulation metadata; no selection procedure is wired in.
208
- if k_mode != "oracle":
209
- raise ValueError(f"k_mode must be 'oracle' (got {k_mode!r}); the harness has no K-selection mode")
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 COHERENT_DEFAULT.get(method, False):
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 m not in KNOWN_METHODS]
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 true module count times --over-cluster-factor (the only mode).")
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
- np.fill_diagonal(rho.values, 1.0)
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
- p = np.minimum(2.0 * sps.norm.sf(np.abs(z_best)) * n_mod, 1.0)
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 = compute_kme(resid, eigengenes, list(resid.index)).values
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
- p = np.minimum(2.0 * sps.t.sf(tval, max(n - 2, 1)) * len(cols), 1.0)
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": (own >= 0) & (gm_per_tx >= 0) & (own != gm_per_tx),
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