multibench-sc 0.2.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.
Files changed (78) hide show
  1. multibench/__init__.py +46 -0
  2. multibench/__main__.py +7 -0
  3. multibench/cli.py +230 -0
  4. multibench/config.py +98 -0
  5. multibench/data/__init__.py +2 -0
  6. multibench/data/catalog.py +132 -0
  7. multibench/data/fetch.py +40 -0
  8. multibench/data/results.py +128 -0
  9. multibench/discover.py +173 -0
  10. multibench/engine/__init__.py +0 -0
  11. multibench/engine/builder.py +52 -0
  12. multibench/engine/drivers/run_matilda.py +84 -0
  13. multibench/engine/drivers/run_stabmap.R +71 -0
  14. multibench/engine/env_groups.yaml +283 -0
  15. multibench/engine/env_locks/MOFA2_env.yml +418 -0
  16. multibench/engine/env_locks/env_moETM.yml +189 -0
  17. multibench/engine/env_locks/env_sciPENN.yml +79 -0
  18. multibench/engine/env_locks/glue.yml +146 -0
  19. multibench/engine/env_locks/matilda.post.sh +18 -0
  20. multibench/engine/env_locks/matilda.yml +117 -0
  21. multibench/engine/env_locks/paste_envi.yml +210 -0
  22. multibench/engine/env_locks/scmb_cobolt3.post.sh +9 -0
  23. multibench/engine/env_locks/scmb_cobolt3.yml +96 -0
  24. multibench/engine/env_locks/scmb_concerto.yml +114 -0
  25. multibench/engine/env_locks/scmb_gpsa2.yml +493 -0
  26. multibench/engine/env_locks/scmb_ipolng2.yml +76 -0
  27. multibench/engine/env_locks/scmb_mira.yml +131 -0
  28. multibench/engine/env_locks/scmb_multigrate2.yml +164 -0
  29. multibench/engine/env_locks/scmb_multivi.yml +141 -0
  30. multibench/engine/env_locks/scmb_r.post.sh +21 -0
  31. multibench/engine/env_locks/scmb_r.yml +396 -0
  32. multibench/engine/env_locks/scmb_scican.yml +125 -0
  33. multibench/engine/env_locks/scmb_scjoint.yml +93 -0
  34. multibench/engine/env_locks/scmb_scmdc.yml +94 -0
  35. multibench/engine/env_locks/scmb_scmm2.yml +60 -0
  36. multibench/engine/env_locks/scmb_scmsi.yml +75 -0
  37. multibench/engine/env_locks/scmb_scmvp4.post.sh +13 -0
  38. multibench/engine/env_locks/scmb_scmvp4.yml +71 -0
  39. multibench/engine/env_locks/scmb_scvi.yml +144 -0
  40. multibench/engine/env_locks/scmb_seurat4b.yml +305 -0
  41. multibench/engine/env_locks/scmb_seurat5.yml +160 -0
  42. multibench/engine/env_locks/scmb_torch.post.sh +17 -0
  43. multibench/engine/env_locks/scmb_torch.yml +135 -0
  44. multibench/engine/env_locks/scmb_torch_v2.post.sh +17 -0
  45. multibench/engine/env_locks/scmb_torch_v2.yml +288 -0
  46. multibench/engine/env_locks/scmb_vimcca.yml +121 -0
  47. multibench/engine/env_locks/scmb_vipcca27.yml +115 -0
  48. multibench/engine/env_locks/spiral_environment.post.sh +17 -0
  49. multibench/engine/env_locks/spiral_environment.yml +644 -0
  50. multibench/engine/env_locks/unitednet.yml +342 -0
  51. multibench/engine/env_specs.yaml +851 -0
  52. multibench/engine/envs.py +669 -0
  53. multibench/engine/ingest.py +123 -0
  54. multibench/engine/io.py +20 -0
  55. multibench/engine/methods.yaml +955 -0
  56. multibench/engine/registry.py +107 -0
  57. multibench/engine/resolve.py +246 -0
  58. multibench/engine/runner.py +183 -0
  59. multibench/engine/schema.py +81 -0
  60. multibench/eval/__init__.py +4 -0
  61. multibench/eval/io.py +42 -0
  62. multibench/eval/pipeline.py +81 -0
  63. multibench/eval/scib.py +236 -0
  64. multibench/files/README_PROVENANCE.txt +5 -0
  65. multibench/files/dataset.csv +72 -0
  66. multibench/files/method.csv +41 -0
  67. multibench/files/metric_full.csv +11 -0
  68. multibench/plot/__init__.py +22 -0
  69. multibench/plot/bar.py +125 -0
  70. multibench/plot/bubble.py +328 -0
  71. multibench/plot/style.py +28 -0
  72. multibench/workflow.py +1008 -0
  73. multibench_sc-0.2.0.dist-info/METADATA +80 -0
  74. multibench_sc-0.2.0.dist-info/RECORD +78 -0
  75. multibench_sc-0.2.0.dist-info/WHEEL +5 -0
  76. multibench_sc-0.2.0.dist-info/entry_points.txt +2 -0
  77. multibench_sc-0.2.0.dist-info/licenses/LICENSE +202 -0
  78. multibench_sc-0.2.0.dist-info/top_level.txt +1 -0
multibench/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """multibench - run and compare single-cell multimodal integration methods.
2
+
3
+ Quickstart
4
+ ----------
5
+ ::
6
+
7
+ import multibench as mtb
8
+
9
+ mtb.list_categories() # the four integration scenarios
10
+ print(mtb.describe_layout()) # how to lay out YOUR dataset
11
+
12
+ mtb.scan("MYDATA") # which methods can run on it (and why not the rest)
13
+
14
+ res = mtb.run_all("MYDATA", "vertical", out_dir="out/")
15
+ print(res.summary) # per-method status + metrics
16
+ print(res.failures) # what did not finish, and why
17
+ res.plot().savefig("compare.png")
18
+
19
+ The four ``category`` values are ``vertical``, ``diagonal``, ``mosaic`` and
20
+ ``cross`` - see :func:`list_categories` for what each one means, and
21
+ :func:`describe_layout` for the filenames each expects.
22
+ """
23
+
24
+ from . import config
25
+ from . import plot
26
+ from . import eval
27
+ from .data import catalog
28
+ from .data.results import load_results, available_datasets
29
+ from .eval.pipeline import evaluate, to_long
30
+ from .engine.runner import run
31
+ from .engine.registry import list_methods, list_tasks
32
+ from .engine.resolve import inputs_for, labels_for
33
+ from .engine import ingest as io
34
+ from .engine import envs as env
35
+ from .discover import find_methods, method_info, params_for
36
+ from .workflow import (scan, run_all, BatchResult, list_categories,
37
+ describe_layout, load_batch, runtime_hint, sweep)
38
+
39
+ __version__ = "0.2.0"
40
+
41
+ __all__ = ["config", "plot", "eval", "catalog", "load_results", "available_datasets",
42
+ "evaluate", "to_long", "run", "list_methods", "list_tasks", "inputs_for",
43
+ "labels_for", "io", "env", "method_info", "find_methods", "params_for",
44
+ "scan", "run_all", "BatchResult",
45
+ "list_categories", "describe_layout", "load_batch",
46
+ "runtime_hint", "sweep", "__version__"]
multibench/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Enable ``python -m multibench`` (equivalent to the ``multibench`` console script)."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
multibench/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """Thin CLI over the multibench Python API."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+
6
+ from . import discover, load_results
7
+ from . import plot as plot_ns
8
+
9
+
10
+ def _cmd_list(args) -> int:
11
+ from .engine import registry
12
+ for m in registry.list_methods(category=args.category, task=args.task,
13
+ runnable=args.runnable or None):
14
+ print(m)
15
+ return 0
16
+
17
+
18
+ def _cmd_find(args) -> int:
19
+ needs = True if args.needs_labels else None
20
+ for m in discover.find_methods(category=args.category, task=args.task,
21
+ needs_labels=needs, atac=args.atac,
22
+ runnable=args.runnable or None):
23
+ print(m)
24
+ return 0
25
+
26
+
27
+ def _cmd_plot(args) -> int:
28
+ if args.kind != "bubble":
29
+ raise SystemExit(f"unknown plot kind {args.kind!r}")
30
+ df = load_results(category=args.category, dataset=args.dataset)
31
+ metrics = args.metrics.split(",") if args.metrics else None
32
+ plot_ns.bubble(df, metrics=metrics, aggregate=args.aggregate, save=args.out)
33
+ print(f"wrote {args.out}")
34
+ return 0
35
+
36
+
37
+ def _parse_inputs(pairs) -> dict:
38
+ out = {}
39
+ for p in pairs or []:
40
+ if "=" not in p:
41
+ raise SystemExit(f"--input must be role=path, got {p!r}")
42
+ role, path = p.split("=", 1)
43
+ if role.strip() == "" or path == "":
44
+ raise SystemExit(f"--input must be role=path with non-empty parts, got {p!r}")
45
+ out[role] = path
46
+ return out
47
+
48
+
49
+ def _cmd_run(args) -> int:
50
+ import multibench
51
+ res = multibench.run(method=args.method, category=args.category, task=args.task,
52
+ inputs=_parse_inputs(args.input), out_dir=args.out,
53
+ cmd_template=args.runner)
54
+ print(f"ran {args.method} -> {res.out_dir}")
55
+ return 0
56
+
57
+
58
+ def _cmd_evaluate(args) -> int:
59
+ import multibench
60
+ df = multibench.evaluate(output=args.output, category=args.category, task=args.task,
61
+ labels=args.labels, clustering=args.cluster)
62
+ if args.out:
63
+ df.to_csv(args.out)
64
+ print(f"wrote {args.out}")
65
+ else:
66
+ print(df.to_string())
67
+ return 0
68
+
69
+
70
+ def _cmd_env(args) -> int:
71
+ from .engine import envs
72
+ cmd = args.env_cmd
73
+ if cmd == "status":
74
+ for r in envs.status():
75
+ mark = "x" if r["exists"] else " "
76
+ tag = r["difficulty"] + ("*" if r["verified_working"] else "")
77
+ print(f"[{mark}] {r['method']:16} {r['group']:16} {tag}")
78
+ return 0
79
+ if cmd == "groups":
80
+ for name, spec in envs.groups().items():
81
+ if spec.get("shared"):
82
+ print(f"{name:16} ({len(spec['members']):2}): {', '.join(spec['members'])}")
83
+ return 0
84
+ if cmd == "plan":
85
+ _mlist = getattr(args, "methods", None)
86
+ _mlist = [m.strip() for m in _mlist.split(",")] if _mlist else None
87
+ for p in envs.plan(category=getattr(args, "category", None), methods=_mlist):
88
+ tag = "shared" if p["shared"] else "own"
89
+ print(f"{p['env']:16} [{tag:6}] <- {', '.join(p['methods'])}")
90
+ return 0
91
+ if cmd == "doctor":
92
+ _mlist = getattr(args, "methods", None)
93
+ _mlist = [m.strip() for m in _mlist.split(",")] if _mlist else None
94
+ rows = envs.doctor(category=getattr(args, "category", None), methods=_mlist)
95
+ for r in rows:
96
+ mark = "x" if r["exists"] else ("L" if r["has_lock"] else "!")
97
+ print(f"[{mark}] {r['env']:18} ({len(r['methods']):2}) <- {', '.join(r['methods'])}")
98
+ missing = [r for r in rows if not r["exists"]]
99
+ nolock = [r["env"] for r in missing if not r["has_lock"]]
100
+ print(f"# {len(rows)} envs needed, {len(missing)} missing"
101
+ + (f"; NO lockfile for: {', '.join(nolock)}" if nolock else ""))
102
+ print("# legend: [x]=installed [L]=missing, lockfile ready (run `multibench env install --run`) [!]=missing, no lockfile")
103
+ return 0
104
+ if cmd == "install":
105
+ _mlist = getattr(args, "methods", None)
106
+ _mlist = [m.strip() for m in _mlist.split(",")] if _mlist else None
107
+ try:
108
+ rows = envs.create_all(category=getattr(args, "category", None),
109
+ methods=_mlist,
110
+ dry_run=not getattr(args, "run", False))
111
+ except RuntimeError as e:
112
+ print(f"error: {e}")
113
+ return 1
114
+ for r in rows:
115
+ state = ("have" if r["exists"]
116
+ else ("BUILD" if r["has_lock"] and getattr(args, "run", False)
117
+ else "build(dry-run)" if r["has_lock"] else "NO-LOCK"))
118
+ print(f"{r['env']:18} [{state:14}] <- {', '.join(r['methods'])}")
119
+ if not getattr(args, "run", False):
120
+ print("# dry-run — add --run to create the missing envs from their lockfiles")
121
+ return 0
122
+ if cmd == "freeze":
123
+ if getattr(args, "all", False):
124
+ for env in envs.required_envs(category=getattr(args, "category", None)):
125
+ try:
126
+ print(f"froze {env} -> {envs.freeze(env)}")
127
+ except Exception as e: # noqa: BLE001 - report per-env, keep going
128
+ print(f"SKIP {env}: {str(e)[:120]}")
129
+ else:
130
+ print(f"froze {args.env} -> {envs.freeze(args.env)}")
131
+ return 0
132
+ if cmd == "create-group":
133
+ cmds = envs.create_group(args.group, dry_run=not getattr(args, "run", False))
134
+ if not getattr(args, "run", False):
135
+ print("# dry-run — add --run to execute:")
136
+ for c in cmds:
137
+ print(" ".join(c))
138
+ else:
139
+ print(f"created group env {args.group}")
140
+ return 0
141
+ method = getattr(args, "method", None)
142
+ name = getattr(args, "name", None)
143
+ if cmd == "recipe":
144
+ for c in envs.create_commands(method, env_name=name):
145
+ print(" ".join(c))
146
+ return 0
147
+ if cmd == "yml":
148
+ y = envs.environment_yml(method, env_name=name)
149
+ out = getattr(args, "out", None)
150
+ if out:
151
+ with open(out, "w") as f:
152
+ f.write(y)
153
+ print(f"wrote {out}")
154
+ else:
155
+ print(y, end="")
156
+ return 0
157
+ if cmd == "create":
158
+ cmds = envs.create(method, env_name=name, dry_run=not getattr(args, "run", False))
159
+ if not getattr(args, "run", False):
160
+ print("# dry-run — add --run to execute:")
161
+ for c in cmds:
162
+ print(" ".join(c))
163
+ else:
164
+ print(f"created environment for {method}")
165
+ return 0
166
+ raise SystemExit(
167
+ "usage: multibench env {status|groups|plan|doctor|install|freeze|recipe|yml|create|create-group}")
168
+
169
+
170
+ def build_parser() -> argparse.ArgumentParser:
171
+ p = argparse.ArgumentParser(prog="multibench")
172
+ sub = p.add_subparsers(dest="command", required=True)
173
+
174
+ pl = sub.add_parser("list"); pl.add_argument("--category"); pl.add_argument("--task")
175
+ pl.add_argument("--runnable", action="store_true",
176
+ help="only methods with a declared variant (usable by run)")
177
+ pl.set_defaults(func=_cmd_list)
178
+
179
+ pf = sub.add_parser("find")
180
+ pf.add_argument("--category"); pf.add_argument("--task")
181
+ pf.add_argument("--needs-labels", action="store_true")
182
+ pf.add_argument("--atac", choices=["peak", "gene_activity"],
183
+ help="filter by ATAC representation the method consumes")
184
+ pf.add_argument("--runnable", action="store_true",
185
+ help="only methods with a declared variant (usable by run)")
186
+ pf.set_defaults(func=_cmd_find)
187
+
188
+ pp = sub.add_parser("plot")
189
+ pp.add_argument("kind")
190
+ pp.add_argument("--category", required=True); pp.add_argument("--dataset")
191
+ pp.add_argument("--metrics"); pp.add_argument("--aggregate", default="dataset")
192
+ pp.add_argument("--out", required=True)
193
+ pp.set_defaults(func=_cmd_plot)
194
+
195
+ pr = sub.add_parser("run")
196
+ pr.add_argument("--method", required=True); pr.add_argument("--category", required=True)
197
+ pr.add_argument("--task", default="clustering")
198
+ pr.add_argument("--input", action="append", help="role=path (repeatable)")
199
+ pr.add_argument("--out", required=True); pr.add_argument("--runner")
200
+ pr.set_defaults(func=_cmd_run)
201
+
202
+ pe = sub.add_parser("evaluate")
203
+ pe.add_argument("--output", required=True)
204
+ pe.add_argument("--category", help="reserved; not used by v1 metrics")
205
+ pe.add_argument("--task", default="clustering")
206
+ pe.add_argument("--labels"); pe.add_argument("--cluster"); pe.add_argument("--out")
207
+ pe.set_defaults(func=_cmd_evaluate)
208
+
209
+ pv = sub.add_parser("env", help="per-method environment recipes")
210
+ ev = pv.add_subparsers(dest="env_cmd", required=True)
211
+ ev.add_parser("status").set_defaults(func=_cmd_env)
212
+ ev.add_parser("groups").set_defaults(func=_cmd_env)
213
+ er = ev.add_parser("recipe"); er.add_argument("method"); er.add_argument("--name"); er.set_defaults(func=_cmd_env)
214
+ ey = ev.add_parser("yml"); ey.add_argument("method"); ey.add_argument("--name"); ey.add_argument("--out"); ey.set_defaults(func=_cmd_env)
215
+ ec = ev.add_parser("create"); ec.add_argument("method"); ec.add_argument("--name"); ec.add_argument("--run", action="store_true"); ec.set_defaults(func=_cmd_env)
216
+ ep = ev.add_parser("plan"); ep.add_argument("--category"); ep.add_argument("--methods", help="comma-separated method names; only their envs"); ep.set_defaults(func=_cmd_env)
217
+ eg = ev.add_parser("create-group"); eg.add_argument("group"); eg.add_argument("--run", action="store_true"); eg.set_defaults(func=_cmd_env)
218
+ edoc = ev.add_parser("doctor", help="preflight: which envs are present / need building"); edoc.add_argument("--category"); edoc.add_argument("--methods", help="comma-separated method names; only their envs"); edoc.set_defaults(func=_cmd_env)
219
+ ei = ev.add_parser("install", help="build every needed env from its lockfile"); ei.add_argument("--category"); ei.add_argument("--methods", help="comma-separated method names; only their envs"); ei.add_argument("--run", action="store_true"); ei.set_defaults(func=_cmd_env)
220
+ ef = ev.add_parser("freeze", help="capture an env (or --all) to a committed lockfile"); ef.add_argument("env", nargs="?"); ef.add_argument("--all", action="store_true"); ef.add_argument("--category"); ef.set_defaults(func=_cmd_env)
221
+ return p
222
+
223
+
224
+ def main(argv=None) -> int:
225
+ args = build_parser().parse_args(argv)
226
+ return args.func(args)
227
+
228
+
229
+ if __name__ == "__main__":
230
+ raise SystemExit(main())
multibench/config.py ADDED
@@ -0,0 +1,98 @@
1
+ """Filesystem paths and on-disk token maps for multibench.
2
+
3
+ The benchmark's result tree uses space-named category folders and a singular
4
+ `scib_metric` top-level dir. Callers use clean tokens (e.g. "vertical"); this
5
+ module translates them to the real folder names.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ __all__ = ["Config", "DEFAULT", "category_folder", "metric_set_dir"]
13
+
14
+ # token -> on-disk space-named folder
15
+ _CATEGORY_FOLDERS = {
16
+ "vertical": "vertical integration",
17
+ "diagonal": "diagonal integration",
18
+ "mosaic": "mosaic integration",
19
+ "cross": "cross integration",
20
+ }
21
+
22
+ # metric-set token -> top-level result dir. Only "scib" is wired in v1
23
+ # (load_results raises NotImplementedError otherwise); other metric sets are not
24
+ # yet exposed here rather than advertising tokens with no working loader.
25
+ _METRIC_SET_DIRS = {
26
+ "scib": "scib_metric",
27
+ }
28
+
29
+ _ROOT = Path(__file__).resolve().parent.parent # <ROOT>
30
+ # Where the package lives decides where large client-side artefacts go. In a
31
+ # repository checkout (source tree / editable install) they sit next to the
32
+ # package, as always; installed as a wheel, _ROOT lands inside site-packages,
33
+ # which must not accumulate datasets or clones - use a per-user cache dir.
34
+ import os as _os
35
+ _IN_REPO = (_ROOT / "pyproject.toml").is_file()
36
+ _BASE = _ROOT if _IN_REPO else (
37
+ Path(_os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")))
38
+ / "multibench")
39
+
40
+
41
+ def category_folder(token: str) -> str:
42
+ """Map a category token to its space-named result folder."""
43
+ try:
44
+ return _CATEGORY_FOLDERS[token]
45
+ except KeyError:
46
+ raise ValueError(
47
+ f"unknown category {token!r}; valid: {sorted(_CATEGORY_FOLDERS)}"
48
+ ) from None
49
+
50
+
51
+ def metric_set_dir(token: str) -> str:
52
+ """Map a metric-set token to its top-level result dir name."""
53
+ try:
54
+ return _METRIC_SET_DIRS[token]
55
+ except KeyError:
56
+ raise ValueError(
57
+ f"unknown metric_set {token!r}; valid: {sorted(_METRIC_SET_DIRS)}"
58
+ ) from None
59
+
60
+
61
+ @dataclass
62
+ class Config:
63
+ """Resolved paths. Override fields to point at custom data locations."""
64
+
65
+ result_path: Path = field(default_factory=lambda: _ROOT / "multibench" / "result")
66
+ files_path: Path = field(default_factory=lambda: _ROOT / "multibench" / "files")
67
+ repo_path: Path = field(default_factory=lambda: _BASE / "scMultiBench_ref")
68
+ data_path: Path = field(default_factory=lambda: _BASE / "data")
69
+
70
+
71
+ # module-level default instance; callers may replace its fields
72
+ DEFAULT = Config()
73
+
74
+
75
+ def ensure_repo(path=None):
76
+ """Return a directory that contains ``tools_scripts/``, provisioning it if needed.
77
+
78
+ Resolution order: the given (or configured) ``repo_path``; the package root
79
+ itself (the merged-repository layout, where ``tools_scripts/`` sits next to
80
+ ``multibench/``); otherwise a one-time shallow clone of the public
81
+ scMultiBench repository into the configured location - which is what makes
82
+ method execution work on a fresh machine or Colab, where the wrapper's
83
+ clone does not carry the 3 GB of upstream method scripts.
84
+ """
85
+ import subprocess
86
+ from pathlib import Path as _P
87
+
88
+ p = _P(path) if path else DEFAULT.repo_path
89
+ if (p / "tools_scripts").is_dir():
90
+ return p
91
+ if (_ROOT / "tools_scripts").is_dir():
92
+ return _ROOT
93
+ print(f"method scripts not found - fetching PYangLab/scMultiBench (once) into {p} ...",
94
+ flush=True)
95
+ subprocess.run(["git", "clone", "--depth", "1",
96
+ "https://github.com/PYangLab/scMultiBench.git", str(p)],
97
+ check=True)
98
+ return p
@@ -0,0 +1,2 @@
1
+
2
+ from .fetch import fetch # noqa: F401
@@ -0,0 +1,132 @@
1
+ """Catalog: parse files/*.csv into typed tables with canonical names."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ import pandas as pd
8
+
9
+ from .. import config
10
+
11
+ __all__ = ["methods", "datasets", "metrics", "canonical_id", "canonical_metric"]
12
+
13
+ # --- canonical method id + aliases -----------------------------------------
14
+ # Canonical id = the registry token. Map known display / result-dir spellings.
15
+ _ALIASES = {
16
+ "seurat v3": "Seurat_v3",
17
+ "seurat_v3": "Seurat_v3",
18
+ "seurat.v3": "Seurat_v3",
19
+ "seurat v4": "Seurat_v4",
20
+ "seurat_v4": "Seurat_v4",
21
+ "seurat.v4": "Seurat_v4",
22
+ "seurat v5": "Seurat_v5",
23
+ "seurat_v5": "Seurat_v5",
24
+ "seurat.v5": "Seurat_v5",
25
+ "seurat(wnn)": "Seurat_WNN",
26
+ "seurat_wnn": "Seurat_WNN",
27
+ "seurat.wnn": "Seurat_WNN",
28
+ "mofa+": "MOFA2",
29
+ "mofa2": "MOFA2",
30
+ "online inmf": "online_iNMF",
31
+ "online_inmf": "online_iNMF",
32
+ "online.inmf": "online_iNMF",
33
+ "ipolng": "iPOLNG",
34
+ }
35
+
36
+
37
+ def canonical_id(name: str) -> str:
38
+ """Return the canonical method id for any known spelling."""
39
+ key = name.strip().lower()
40
+ if key in _ALIASES:
41
+ return _ALIASES[key]
42
+ # default: collapse separators to underscore, keep original casing token
43
+ return re.sub(r"[ .]+", "_", name.strip())
44
+
45
+
46
+ # --- metric code canonicalization ------------------------------------------
47
+ _METRIC_CANON = {
48
+ "kbet": "kBET",
49
+ "ifi": "iF1",
50
+ "if1": "iF1",
51
+ "ari": "ARI",
52
+ "nmi": "NMI",
53
+ "asw": "ASW",
54
+ "iasw": "iASW",
55
+ "clisi": "cLISI",
56
+ "ilisi": "iLISI",
57
+ "gc": "GC",
58
+ "asw_batch": "ASW_batch",
59
+ "pcr": "PCR",
60
+ # raw scIB long-names -> canonical codes (some metric.csv use these)
61
+ "ari_cluster/label": "ARI",
62
+ "nmi_cluster/label": "NMI",
63
+ "asw_label": "ASW",
64
+ "isolated_label_f1": "iF1",
65
+ "isolated_label_silhouette": "iASW",
66
+ }
67
+
68
+
69
+ def canonical_metric(code: str) -> str | None:
70
+ """Canonicalize a raw metric short-code; None for blank/unknown-empty."""
71
+ if code is None:
72
+ return None
73
+ key = str(code).strip().lower()
74
+ if key == "" or key == "nan":
75
+ return None
76
+ return _METRIC_CANON.get(key, str(code).strip())
77
+
78
+
79
+ # --- tables ----------------------------------------------------------------
80
+ def _split_multivalue(cell: object) -> list[str]:
81
+ if pd.isna(cell):
82
+ return []
83
+ parts = re.split(r"[\n;,]+", str(cell))
84
+ out = []
85
+ for p in parts:
86
+ t = p.strip().lower().replace(" integration", "").replace("integration", "").strip()
87
+ if t:
88
+ out.append(t)
89
+ return out
90
+
91
+
92
+ def methods(files_dir: Path | str | None = None) -> pd.DataFrame:
93
+ """Return the methods table with normalized columns and list-valued cats/tasks."""
94
+ if files_dir is None:
95
+ files_dir = config.DEFAULT.files_path
96
+ raw = pd.read_csv(Path(files_dir) / "method.csv")
97
+ # normalize column access by stripping whitespace/newlines
98
+ cols = {c: c.strip().replace("\n", " ").strip() for c in raw.columns}
99
+ raw = raw.rename(columns=cols)
100
+ out = pd.DataFrame()
101
+ out["method"] = raw["Methods"].astype(str).str.strip()
102
+ out["canonical_id"] = out["method"].map(canonical_id)
103
+ out["language"] = raw["Programming Language"].astype(str).str.strip().str.lower()
104
+ out["deep_learning"] = raw["Deep Learning"].astype(str).str.strip()
105
+ out["atac"] = raw["Peak/Gene Activity"].astype(str).str.strip()
106
+ out["output"] = raw["Output"].astype(str).str.strip()
107
+ out["needs_labels"] = (
108
+ raw["CellType Information Required"].astype(str).str.strip().str.lower().isin(["yes", "y", "true"])
109
+ )
110
+ out["categories"] = raw["Integration Categories"].map(_split_multivalue)
111
+ out["tasks"] = raw["Task Categories"].map(_split_multivalue)
112
+ return out
113
+
114
+
115
+ def datasets(files_dir: Path | str | None = None) -> pd.DataFrame:
116
+ """Return the datasets table with a derived `simulated` flag."""
117
+ if files_dir is None:
118
+ files_dir = config.DEFAULT.files_path
119
+ raw = pd.read_csv(Path(files_dir) / "dataset.csv")
120
+ raw = raw.rename(columns={c: c.strip() for c in raw.columns})
121
+ raw = raw.dropna(how="all", axis=1).dropna(how="all", axis=0)
122
+ name_col = "dataset name"
123
+ raw["simulated"] = raw[name_col].astype(str).str.strip().str.upper().str.startswith("SD")
124
+ return raw
125
+
126
+
127
+ def metrics(files_dir: Path | str | None = None) -> pd.DataFrame:
128
+ """Return the metric details table."""
129
+ if files_dir is None:
130
+ files_dir = config.DEFAULT.files_path
131
+ raw = pd.read_csv(Path(files_dir) / "metric_full.csv")
132
+ return raw.rename(columns={c: c.strip() for c in raw.columns})
@@ -0,0 +1,40 @@
1
+ """Download reference datasets from the repository's release assets."""
2
+ from __future__ import annotations
3
+
4
+ import tarfile
5
+ import urllib.request
6
+ from pathlib import Path
7
+
8
+ from .. import config
9
+
10
+ RELEASE_URL = "https://github.com/DSichang/scMultiBench/releases/download/data-v1"
11
+
12
+ # datasets published as release assets, with approximate download sizes
13
+ AVAILABLE = {"D11": "11 MB", "D28": "137 MB", "D45": "290 MB",
14
+ "D46": "97 MB", "D52": "179 MB"}
15
+
16
+
17
+ def fetch(*datasets: str, data_path=None, quiet: bool = False) -> Path:
18
+ """Ensure the named reference datasets exist locally, downloading if needed.
19
+
20
+ Idempotent: datasets already present under the data root are left alone.
21
+ Returns the data root. The full 65-dataset collection is linked from the
22
+ scMultiBench README; this helper covers the tutorial reference sets.
23
+ """
24
+ root = Path(data_path) if data_path is not None else config.DEFAULT.data_path
25
+ for ds in datasets:
26
+ if (root / ds).is_dir():
27
+ continue
28
+ if ds not in AVAILABLE:
29
+ raise ValueError(
30
+ f"{ds!r} is not in the release assets ({', '.join(sorted(AVAILABLE))}); "
31
+ "see 'Get the data' in the installation guide for the full collection")
32
+ root.mkdir(parents=True, exist_ok=True)
33
+ if not quiet:
34
+ print(f"downloading {ds} ({AVAILABLE[ds]}) ...", flush=True)
35
+ tgz, _ = urllib.request.urlretrieve(f"{RELEASE_URL}/{ds}.tar.gz")
36
+ with tarfile.open(tgz) as t:
37
+ t.extractall(root)
38
+ if not (root / ds).is_dir():
39
+ raise RuntimeError(f"downloaded archive did not contain {ds}/")
40
+ return root