mrsuite 0.0.1__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.
- mrsuite/__init__.py +54 -0
- mrsuite/basic_driver.py +112 -0
- mrsuite/benchmark/__init__.py +24 -0
- mrsuite/benchmark/radar.py +247 -0
- mrsuite/benchmark/scoring.py +554 -0
- mrsuite/bridges/__init__.py +21 -0
- mrsuite/bridges/augmentrum.py +165 -0
- mrsuite/bridges/base.py +349 -0
- mrsuite/bridges/fid_a.py +861 -0
- mrsuite/bridges/fsl_mrs.py +604 -0
- mrsuite/bridges/lcmodel.py +177 -0
- mrsuite/bridges/mrspeclab.py +386 -0
- mrsuite/bridges/mrspeclab_driver.py +141 -0
- mrsuite/bridges/nifti_mrs.py +99 -0
- mrsuite/bridges/osprey.py +558 -0
- mrsuite/bridges/pyamares.py +315 -0
- mrsuite/bridges/spant.py +383 -0
- mrsuite/bridges/suspect.py +331 -0
- mrsuite/bridges/tarquin.py +211 -0
- mrsuite/bridges/vespa.py +259 -0
- mrsuite/collect.py +364 -0
- mrsuite/core/__init__.py +0 -0
- mrsuite/core/basis.py +230 -0
- mrsuite/core/method.py +370 -0
- mrsuite/core/parallel.py +158 -0
- mrsuite/core/pipeline.py +613 -0
- mrsuite/core/provenance.py +226 -0
- mrsuite/core/result.py +243 -0
- mrsuite/datasets/__init__.py +13 -0
- mrsuite/datasets/agnostic.py +126 -0
- mrsuite/datasets/challenge2016.py +501 -0
- mrsuite/datasets/mr_science_lab.py +149 -0
- mrsuite/datasets/mrsi_challenge.py +624 -0
- mrsuite/datasets/openneuro.py +195 -0
- mrsuite/externals.py +236 -0
- mrsuite/gui/__init__.py +13 -0
- mrsuite/gui/__main__.py +107 -0
- mrsuite/gui/app.py +1872 -0
- mrsuite/gui/blocks.py +947 -0
- mrsuite/gui/canvas.js +183 -0
- mrsuite/gui/canvas.py +134 -0
- mrsuite/gui/catalog.py +203 -0
- mrsuite/gui/files.py +135 -0
- mrsuite/gui/graph.py +1257 -0
- mrsuite/gui/static/logo.png +0 -0
- mrsuite/gui/static/mrsuite.css +237 -0
- mrsuite/gui/vendor/vue-flow-background/LICENSE +20 -0
- mrsuite/gui/vendor/vue-flow-background/index.js +126 -0
- mrsuite/gui/vendor/vue-flow-core/LICENSE +20 -0
- mrsuite/gui/vendor/vue-flow-core/index.js +10186 -0
- mrsuite/gui/vendor/vue-flow-core/style.css +248 -0
- mrsuite/gui/vendor/vue-flow-core/theme-default.css +143 -0
- mrsuite/io.py +426 -0
- mrsuite/models/__init__.py +97 -0
- mrsuite/models/base.py +515 -0
- mrsuite/models/convert.py +109 -0
- mrsuite/models/mlp.py +223 -0
- mrsuite/models/pytorch.py +89 -0
- mrsuite/models/reference.py +116 -0
- mrsuite/models/wand.py +263 -0
- mrsuite/mrsi.py +167 -0
- mrsuite/octave/__init__.py +285 -0
- mrsuite/octave/docker.py +194 -0
- mrsuite/octave/dockerfile +42 -0
- mrsuite/octave/matlab.py +172 -0
- mrsuite/octave/sessions.py +91 -0
- mrsuite/octave/shims/contains.m +42 -0
- mrsuite/octave/shims/fftshift.m +52 -0
- mrsuite/octave/shims/hilbert.m +42 -0
- mrsuite/octave/shims/lbfgsb.m +200 -0
- mrsuite/octave/shims/lsqcurvefit.m +22 -0
- mrsuite/octave/shims/lsqnonlin.m +45 -0
- mrsuite/octave/shims/mrsuite_lm.m +99 -0
- mrsuite/octave/shims/nanmedian.m +16 -0
- mrsuite/octave/shims/nlinfit.m +132 -0
- mrsuite/octave/shims/optimoptions.m +11 -0
- mrsuite/octave/shims/optimset.m +39 -0
- mrsuite/octave/shims/padarray.m +47 -0
- mrsuite/octave/shims/sscanf.m +59 -0
- mrsuite/octave/shims/statset.m +56 -0
- mrsuite/octave/src/lbfgsb_quadratic.c +208 -0
- mrsuite/r/__init__.py +135 -0
- mrsuite/r/dockerfile +22 -0
- mrsuite/r/driver.R +135 -0
- mrsuite/simulators/__init__.py +13 -0
- mrsuite/simulators/bases.py +251 -0
- mrsuite/simulators/digital_phantom.py +111 -0
- mrsuite/simulators/digital_phantom_driver.py +60 -0
- mrsuite/simulators/mrs_sim.py +636 -0
- mrsuite/simulators/mrs_sim_driver.py +202 -0
- mrsuite/simulators/mrsandbox.py +426 -0
- mrsuite/simulators/pulses.py +86 -0
- mrsuite/simulators/synthetic.py +143 -0
- mrsuite/testing.py +217 -0
- mrsuite/training/__init__.py +21 -0
- mrsuite/training/loop.py +177 -0
- mrsuite/training/recipe.py +198 -0
- mrsuite/training/source.py +151 -0
- mrsuite-0.0.1.dist-info/METADATA +161 -0
- mrsuite-0.0.1.dist-info/RECORD +104 -0
- mrsuite-0.0.1.dist-info/WHEEL +5 -0
- mrsuite-0.0.1.dist-info/entry_points.txt +2 -0
- mrsuite-0.0.1.dist-info/licenses/LICENSE +21 -0
- mrsuite-0.0.1.dist-info/top_level.txt +1 -0
mrsuite/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# __init__.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 2026-08-08 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: Package entry point. Exposes the toolbox bridges directly (mrsuite.fid_a, #
|
|
10
|
+
# mrsuite.fsl_mrs, mrsuite.osprey, mrsuite.lcmodel) and mrsuite.method, which makes #
|
|
11
|
+
# a function of your own a method, and stamps the shared NIfTI-MRS+ provenance. #
|
|
12
|
+
# Bridges are imported lazily: resolving an Octave-backed one would start a #
|
|
13
|
+
# session, and the FSL-MRS one pulls in the whole FSL-MRS stack, neither of which #
|
|
14
|
+
# a caller who only wants the pipeline should pay for. #
|
|
15
|
+
# #
|
|
16
|
+
####################################################################################################
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
#*************#
|
|
20
|
+
# imports #
|
|
21
|
+
#*************#
|
|
22
|
+
import importlib
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import nifti_mrs_plus
|
|
26
|
+
|
|
27
|
+
from mrsuite.core.method import Method, method
|
|
28
|
+
|
|
29
|
+
__version__ = "0.0.1"
|
|
30
|
+
|
|
31
|
+
# Same pattern as Augmentrum: stamp the shared wrapper's provenance metadata so
|
|
32
|
+
# processed data records which toolbox touched it.
|
|
33
|
+
nifti_mrs_plus.set_provenance("MRSuite", __version__)
|
|
34
|
+
|
|
35
|
+
_BRIDGES = {"augmentrum", "fid_a", "fsl_mrs", "lcmodel", "mrspeclab", "nifti_mrs", "osprey",
|
|
36
|
+
"pyamares", "spant", "suspect", "tarquin", "vespa"}
|
|
37
|
+
|
|
38
|
+
__all__ = ["__version__", "Method", "method", *sorted(_BRIDGES)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
#*************#
|
|
42
|
+
# getattr #
|
|
43
|
+
#*************#
|
|
44
|
+
def __getattr__(name: str) -> Any:
|
|
45
|
+
if name in _BRIDGES:
|
|
46
|
+
return importlib.import_module(f"mrsuite.bridges.{name}")
|
|
47
|
+
raise AttributeError(f"module 'mrsuite' has no attribute '{name}'")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#*********#
|
|
51
|
+
# dir #
|
|
52
|
+
#*********#
|
|
53
|
+
def __dir__():
|
|
54
|
+
return sorted(set(globals()) | _BRIDGES)
|
mrsuite/basic_driver.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# basic_driver.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 2026-10-09 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: Reads and writes basis sets with BasiC, for mrsuite.io. Not imported by MRSuite: BasiC #
|
|
10
|
+
# is a folder of scripts importing each other as top-level modules (core, readers, #
|
|
11
|
+
# writers), so it runs in a process of its own. FIDs come and go as .npz; what BasiC #
|
|
12
|
+
# prints goes to stderr; the job's answer goes to stdout, as JSON; a failure ends it #
|
|
13
|
+
# with one line saying why. #
|
|
14
|
+
# #
|
|
15
|
+
# Usage: python basic_driver.py '<job as JSON>' #
|
|
16
|
+
# #
|
|
17
|
+
####################################################################################################
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
#*************#
|
|
21
|
+
# imports #
|
|
22
|
+
#*************#
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#**********#
|
|
31
|
+
# fail #
|
|
32
|
+
#**********#
|
|
33
|
+
def fail(message):
|
|
34
|
+
sys.exit(" ".join(str(message).split()))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#**********#
|
|
38
|
+
# read #
|
|
39
|
+
#**********#
|
|
40
|
+
def read(job):
|
|
41
|
+
"""The files in the format given or detected, with the parameters given where they state
|
|
42
|
+
none, as one array of FIDs (to ``result``) and what is said of them; the echo time and
|
|
43
|
+
sequence as the basis functions hold them (a reader gives 0 for an echo time not
|
|
44
|
+
stated), else as given."""
|
|
45
|
+
from core.detect import detect_format
|
|
46
|
+
from core.load import fill_missing, load_basis
|
|
47
|
+
|
|
48
|
+
found = detect_format(job["files"])
|
|
49
|
+
fmt = job["format"]
|
|
50
|
+
if fmt == "auto":
|
|
51
|
+
if found["status"] == "ambiguous":
|
|
52
|
+
fail(f"{found['message']} Choose one as format: "
|
|
53
|
+
+ ", ".join(map(repr, found["ambiguous_options"])))
|
|
54
|
+
if found["status"] == "failed":
|
|
55
|
+
fail(found["message"])
|
|
56
|
+
fmt = found["format"]
|
|
57
|
+
stated = found["data"]
|
|
58
|
+
given = {k: job[k] for k in ("sw", "sf", "te")}
|
|
59
|
+
basis = load_basis(job["files"], fmt, sw=stated.get("sw") or given["sw"],
|
|
60
|
+
sf=stated.get("sf") or given["sf"])
|
|
61
|
+
if not basis:
|
|
62
|
+
fail("it found no basis functions in the files")
|
|
63
|
+
fill_missing(basis, given)
|
|
64
|
+
lacking = [k for k in ("sw", "sf") if not all(b.get(k) for b in basis)]
|
|
65
|
+
if lacking:
|
|
66
|
+
fail(f"the files do not state {' or '.join(lacking)}: give "
|
|
67
|
+
+ ", ".join(f"{k}=" for k in lacking))
|
|
68
|
+
if len({len(np.ravel(b["fid"])) for b in basis}) > 1:
|
|
69
|
+
fail("its basis functions differ in length")
|
|
70
|
+
np.savez(job["result"], fids=np.array([np.ravel(b["fid"]) for b in basis]))
|
|
71
|
+
first = basis[0]
|
|
72
|
+
return {"names": [b["name"] for b in basis], "bandwidth": float(first["sw"]),
|
|
73
|
+
"frequency": float(first["sf"]), "centre": first.get("centerFreq"),
|
|
74
|
+
"te": first.get("te") or given["te"], "seq": first.get("seq"), "format": fmt}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#***********#
|
|
78
|
+
# write #
|
|
79
|
+
#***********#
|
|
80
|
+
def write(job):
|
|
81
|
+
"""The job's basis set (FIDs from ``fids``) written for a tool BasiC writes for; the
|
|
82
|
+
files written."""
|
|
83
|
+
from core.formats import ALL_TOOLS
|
|
84
|
+
from core.write import write_basis
|
|
85
|
+
|
|
86
|
+
tools = {t["id"]: [f["id"] for f in t["formats"]] for t in ALL_TOOLS if not t["dimmed"]}
|
|
87
|
+
if job["tool"] not in tools:
|
|
88
|
+
fail(f"no tool {job['tool']!r}; one of: {', '.join(tools)}")
|
|
89
|
+
outputs = tools[job["tool"]]
|
|
90
|
+
if job["output"] != "default" and job["output"] not in outputs:
|
|
91
|
+
fail(f"{job['tool']} writes output 'default'"
|
|
92
|
+
+ "".join(f", {o!r}" for o in outputs) + f", not {job['output']!r}")
|
|
93
|
+
fids = np.load(job["fids"])["fids"]
|
|
94
|
+
basis = [{"fid": fid, "name": name, "n": len(fid), "sw": job["bandwidth"],
|
|
95
|
+
"sf": job["frequency"], "centerFreq": job["centre"], "te": job["te"],
|
|
96
|
+
"seq": job["seq"]} for name, fid in zip(job["names"], fids)]
|
|
97
|
+
written = write_basis(basis, job["tool"], job["output"], job["folder"], te=job["te"],
|
|
98
|
+
seq=job["seq"])
|
|
99
|
+
if not written: # its writers go on without what they fail
|
|
100
|
+
fail("it wrote nothing")
|
|
101
|
+
return written
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
try:
|
|
106
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
107
|
+
job = json.loads(sys.argv[1])
|
|
108
|
+
sys.path.insert(0, job["root"])
|
|
109
|
+
answer = {"read": read, "write": write}[job["task"]](job)
|
|
110
|
+
except Exception as err: # noqa: BLE001 - said, in one line
|
|
111
|
+
fail(f"{type(err).__name__}: {err}")
|
|
112
|
+
print(json.dumps(answer))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# __init__.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 2026-10-01 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: Benchmarking on MRSValidate. Fits and ground truth become its ObservationSets on a #
|
|
10
|
+
# reference the caller names (water, tCr, the optimal scale or none), every metric runs #
|
|
11
|
+
# as a step of its analysis plan, and radar draws the resulting scores. #
|
|
12
|
+
# #
|
|
13
|
+
####################################################################################################
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
#*************#
|
|
17
|
+
# imports #
|
|
18
|
+
#*************#
|
|
19
|
+
from mrsuite.benchmark.radar import plot_scores, radar
|
|
20
|
+
from mrsuite.benchmark.scoring import (REFERENCES, analyses, bias, coverage, figure, mae,
|
|
21
|
+
observations, optimal_scale, score)
|
|
22
|
+
|
|
23
|
+
__all__ = ["REFERENCES", "observations", "optimal_scale", "score", "analyses", "figure",
|
|
24
|
+
"coverage", "mae", "bias", "radar", "plot_scores"]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# radar.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 2026-10-01 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: A radar plot of benchmark scores. Like MRSValidate's own plots it only draws values #
|
|
10
|
+
# computed before: one polygon per method, one spoke per metric. #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
#*************#
|
|
16
|
+
# imports #
|
|
17
|
+
#*************#
|
|
18
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# how a metric reads, by its name in the score tables: higher is better for agreement and
|
|
25
|
+
# correlation, and for Shapiro-Wilk's W; a signed error (bias, a mean or median error,
|
|
26
|
+
# Bland-Altman's, whose score is its bias, the equivalence tests', whose score is their
|
|
27
|
+
# intercept) is best at 0; a slope (and the original concise score, slope times R²) is best
|
|
28
|
+
# at 1; the rest are errors and spreads, lower is better
|
|
29
|
+
HIGHER = {"lin_ccc", "robust_ccc", "pearson_r", "r_squared_direct", "adjusted_r_squared",
|
|
30
|
+
"median_centered_correlation", "concise_score_manuscript", "shapiro_wilk"}
|
|
31
|
+
ONE = {"passing_bablok", "deming", "theil_sen", "regression_metrics", "concise_score_original"}
|
|
32
|
+
SIGNED = {"bias", "mpe", "mepe", "mean", "median", "bland_altman", "equivalence_test_1",
|
|
33
|
+
"equivalence_test_2", "equivalence_test_3"}
|
|
34
|
+
OWN = {"mae", "bias", "mosae"} # MRSuite's own error measures (scoring.py)
|
|
35
|
+
# the one analysis MRSValidate runs under a name other than its function's (meta.method)
|
|
36
|
+
RUN_AS = {"manuscript_robust_ccc": "robust_ccc"}
|
|
37
|
+
# how a metric is labelled on the radar, by its name in the score tables; others by that name
|
|
38
|
+
LABELS = {"mae": "MAE", "mosae": "MOSAE", "bias": "bias", "rmse": "RMSE", "mape": "MAPE",
|
|
39
|
+
"lin_ccc": "Lin's CCC", "robust_ccc": "robust CCC", "pearson_r": "Pearson r",
|
|
40
|
+
"r_squared_direct": "R²", "bland_altman": "Bland-Altman bias", "robust_sd": "robust SD",
|
|
41
|
+
"iqr": "IQR"}
|
|
42
|
+
# an aspect by the module its analyses sit in, where MRSValidate's name says less
|
|
43
|
+
ASPECTS = {"descriptive": "Error distribution", "normality": "Normality of errors"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#************#
|
|
47
|
+
# metric #
|
|
48
|
+
#************#
|
|
49
|
+
def _metric(measure: str) -> str:
|
|
50
|
+
"""The metric a measure is: its name before any reference or metabolite."""
|
|
51
|
+
return measure.split(" ")[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
#***********#
|
|
55
|
+
# radar #
|
|
56
|
+
#***********#
|
|
57
|
+
def radar(values: pd.DataFrame, limits: Optional[Dict[str, Tuple[float, float]]] = None,
|
|
58
|
+
ax: Any = None) -> Any:
|
|
59
|
+
"""
|
|
60
|
+
Draw scores on a radar plot: one polygon per row (a method), one spoke per column
|
|
61
|
+
(a metric), the best method at the rim of each spoke and the worst at its centre, on a
|
|
62
|
+
web of polygon rings, a point at each corner, no fill.
|
|
63
|
+
|
|
64
|
+
It computes no statistic, as MRSValidate's plots compute none; score's table becomes
|
|
65
|
+
its input by picking one reference and each metric's own field, e.g.
|
|
66
|
+
``table.query("reference == 'optimal' and metabolite == 'all'").pivot(index="method",
|
|
67
|
+
columns="metric", values="value")`` for metrics filed under 'value' (Lin's CCC is
|
|
68
|
+
under 'rho_c'). Each spoke runs from the worst value among the methods to the best,
|
|
69
|
+
which way round the metric says (``HIGHER``, else lower is better); a signed metric
|
|
70
|
+
such as bias is best at 0, so it is drawn by its magnitude (``SIGNED``), and a slope
|
|
71
|
+
is best at 1, so by its distance from 1 (``ONE``). So every
|
|
72
|
+
metric sits on 0-1, comparable across spokes, which is axis geometry only. Where all
|
|
73
|
+
methods score the same, all sit at the rim. A column given limits (0, 1) is drawn as
|
|
74
|
+
it is, labelled by its name alone.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
values (pd.DataFrame): Methods by metrics.
|
|
78
|
+
limits (Optional[Dict[str, Tuple[float, float]]]): Per metric, the values at the
|
|
79
|
+
centre and at the rim instead, e.g. a lower-is-better metric's (worst, best).
|
|
80
|
+
ax (Any): A polar axes to draw on; a new figure's when None.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Any: The axes.
|
|
84
|
+
"""
|
|
85
|
+
import matplotlib.pyplot as plt
|
|
86
|
+
|
|
87
|
+
values = values.copy()
|
|
88
|
+
for c in values.columns:
|
|
89
|
+
if _metric(c) in SIGNED:
|
|
90
|
+
values[c] = values[c].abs()
|
|
91
|
+
elif _metric(c) in ONE:
|
|
92
|
+
values[c] = (values[c] - 1).abs()
|
|
93
|
+
limits = {c: (limits or {}).get(c, (values[c].min(), values[c].max()) if _metric(c) in HIGHER
|
|
94
|
+
else (values[c].max(), values[c].min()))
|
|
95
|
+
for c in values.columns}
|
|
96
|
+
angles = np.linspace(0, 2 * np.pi, len(limits), endpoint=False)
|
|
97
|
+
if ax is None:
|
|
98
|
+
_, ax = plt.subplots(subplot_kw={"projection": "polar"})
|
|
99
|
+
ax.set_facecolor("none")
|
|
100
|
+
ax.figure.set_facecolor("none")
|
|
101
|
+
closed = np.append(angles, angles[0])
|
|
102
|
+
|
|
103
|
+
for r in (0.2, 0.4, 0.6, 0.8, 1.0): # a web: rings as polygons
|
|
104
|
+
ax.plot(closed, [r] * len(closed), color="grey", alpha=0.55 if r == 1.0 else 0.35,
|
|
105
|
+
linewidth=1.6 if r == 1.0 else 1.0, zorder=0)
|
|
106
|
+
_scale(ax)
|
|
107
|
+
for method, row in values.iterrows():
|
|
108
|
+
radius = [(row[c] - lo) / (hi - lo) if hi != lo else 1.0 for c, (lo, hi) in limits.items()]
|
|
109
|
+
radius = [max(x, 0.05) for x in radius] # never a point at the centre
|
|
110
|
+
ax.plot(closed, radius + radius[:1], label=method, linewidth=2.5, marker="o",
|
|
111
|
+
markersize=7, zorder=2)
|
|
112
|
+
|
|
113
|
+
ax.set_xticks(angles)
|
|
114
|
+
ax.set_xticklabels([c if (lo, hi) == (0, 1) else f"{c}\n{lo:.3g} to {hi:.3g}"
|
|
115
|
+
for c, (lo, hi) in limits.items()], fontsize=13, color="#9aa4b5")
|
|
116
|
+
ax.tick_params(axis="x", pad=28) # the labels clear of the rim
|
|
117
|
+
for angle, label in zip(np.degrees(angles), ax.get_xticklabels()):
|
|
118
|
+
shown = (90 - angle) % 360 # the first corner on top, clockwise
|
|
119
|
+
side = np.isclose(shown, 90) or np.isclose(shown, 270)
|
|
120
|
+
label.set_horizontalalignment("center" if side else
|
|
121
|
+
"right" if 90 < shown < 270 else "left")
|
|
122
|
+
label.set_verticalalignment("bottom" if np.isclose(shown, 90) else
|
|
123
|
+
"top" if np.isclose(shown, 270) else "center")
|
|
124
|
+
ax.set_yticks([])
|
|
125
|
+
ax.set_ylim(0, 1)
|
|
126
|
+
ax.xaxis.grid(True, color="grey", alpha=0.45, linewidth=1.0)
|
|
127
|
+
ax.yaxis.grid(False)
|
|
128
|
+
ax.spines["polar"].set_visible(False)
|
|
129
|
+
ax.set_theta_offset(np.pi / 2) # the first corner at the top
|
|
130
|
+
ax.set_theta_direction(-1)
|
|
131
|
+
if len(values):
|
|
132
|
+
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.2), frameon=False,
|
|
133
|
+
labelcolor="#9aa4b5", fontsize=11, ncol=min(len(values), 3))
|
|
134
|
+
return ax
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
#***********#
|
|
138
|
+
# scale #
|
|
139
|
+
#***********#
|
|
140
|
+
def _scale(ax: Any) -> None:
|
|
141
|
+
"""The web's scale, 0 at the centre to 1 at the rim, beside the first spoke."""
|
|
142
|
+
for r in (0.0, 0.2, 0.4, 0.6, 0.8, 1.0):
|
|
143
|
+
ax.text(0, r, f" {r:g}", fontsize=9, color="grey", ha="left", va="bottom", zorder=1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
#*****************#
|
|
147
|
+
# plot scores #
|
|
148
|
+
#*****************#
|
|
149
|
+
def plot_scores(scores: Union[pd.DataFrame, Dict[str, pd.DataFrame]], ax: Any = None,
|
|
150
|
+
by: str = "aspect") -> Any:
|
|
151
|
+
"""
|
|
152
|
+
Score tables on a radar plot: one corner per aspect of MRSValidate (errors, agreement,
|
|
153
|
+
regression, method comparison, ...), one polygon per method, as bsuite draws its agents
|
|
154
|
+
over its capabilities. Every measure in the tables is scaled from the worst method to the
|
|
155
|
+
best (see ``radar``), and a corner is the mean of its aspect's measures, so a corner
|
|
156
|
+
fills as more of its measures are wired in; each corner names its measures. Or, by
|
|
157
|
+
measure, a spoke per measure. Every spoke runs 0 to 1 alike. The pooled rows
|
|
158
|
+
(metabolite 'all') count where a table has them, else each metabolite's; a measure on
|
|
159
|
+
several references counts once per reference, and is labelled with it.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
scores (Union[pd.DataFrame, Dict[str, pd.DataFrame]]): One table of score's, or
|
|
163
|
+
several by name, e.g. one per measure.
|
|
164
|
+
ax (Any): A polar axes to draw on; else a new figure's, made without pyplot, so
|
|
165
|
+
drawing it in a thread is safe and nothing is left open.
|
|
166
|
+
by (str): 'aspect', a corner per aspect, or 'measure', a spoke per measure.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Any: The axes.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
ValueError: For a method without a score for a measure (one that failed, or an
|
|
173
|
+
interval's), or one measure given twice. No tables at all draw an empty radar,
|
|
174
|
+
to be filled.
|
|
175
|
+
"""
|
|
176
|
+
from matplotlib.figure import Figure
|
|
177
|
+
|
|
178
|
+
tables = list(scores.values()) if isinstance(scores, dict) else [scores]
|
|
179
|
+
if ax is None:
|
|
180
|
+
ax = Figure(figsize=(7, 7), layout="constrained").add_subplot(projection="polar")
|
|
181
|
+
if not tables: # the web, waiting for corners
|
|
182
|
+
ax.set_facecolor("none")
|
|
183
|
+
ax.figure.set_facecolor("none")
|
|
184
|
+
for r in (0.2, 0.4, 0.6, 0.8, 1.0):
|
|
185
|
+
ax.plot(np.linspace(0, 2 * np.pi, 100), [r] * 100, color="grey",
|
|
186
|
+
alpha=0.55 if r == 1.0 else 0.35, linewidth=1.6 if r == 1.0 else 1.0)
|
|
187
|
+
ax.set_theta_offset(np.pi / 2) # its scale on top, as a radar's
|
|
188
|
+
_scale(ax)
|
|
189
|
+
ax.set_xticks([])
|
|
190
|
+
ax.set_yticks([])
|
|
191
|
+
ax.set_ylim(0, 1)
|
|
192
|
+
ax.spines["polar"].set_visible(False)
|
|
193
|
+
ax.set_title("No scores yet: wire score tables in, a corner per aspect",
|
|
194
|
+
color="#9aa4b5", fontsize=12)
|
|
195
|
+
return ax
|
|
196
|
+
picked = pd.concat([t[t["metabolite"] == "all"] if (t["metabolite"] == "all").any() else t
|
|
197
|
+
for t in tables])
|
|
198
|
+
picked = picked.assign(measure=picked["metric"] + " (" + picked["reference"] + ")"
|
|
199
|
+
+ np.where(picked["metabolite"] == "all", "",
|
|
200
|
+
" " + picked["metabolite"]))
|
|
201
|
+
twice = picked[picked.duplicated(["method", "measure"])]["measure"].unique()
|
|
202
|
+
if len(twice):
|
|
203
|
+
raise ValueError(f"given twice: {', '.join(twice)}")
|
|
204
|
+
values = picked.pivot(index="method", columns="measure", values="score")
|
|
205
|
+
missing = [f"{measure} ({method})" for measure in values.columns
|
|
206
|
+
for method in values.index[~np.isfinite(values[measure])]]
|
|
207
|
+
if missing:
|
|
208
|
+
raise ValueError(f"no score for {', '.join(missing)}")
|
|
209
|
+
|
|
210
|
+
if by not in ("aspect", "measure"):
|
|
211
|
+
raise ValueError(f"a radar is by 'aspect' or by 'measure', not {by!r}")
|
|
212
|
+
analysis = dict(zip(picked["measure"], picked["analysis"].replace(RUN_AS)))
|
|
213
|
+
several = picked["reference"].nunique() > 1
|
|
214
|
+
scaled, labels = pd.DataFrame(index=values.index), {}
|
|
215
|
+
for c in values.columns: # each measure 0 (worst) to 1
|
|
216
|
+
names = ({_metric(c)} if _metric(c) in OWN else # MRSuite's own by its name,
|
|
217
|
+
{_metric(c), analysis[c]}) # others as named, or as run
|
|
218
|
+
v = (values[c].abs() if names & SIGNED else
|
|
219
|
+
(values[c] - 1).abs() if names & ONE else values[c])
|
|
220
|
+
lo, hi = (v.min(), v.max()) if names & HIGHER else (v.max(), v.min())
|
|
221
|
+
scaled[c] = (v - lo) / (hi - lo) if hi != lo else 1.0
|
|
222
|
+
label = LABELS.get(_metric(c), _metric(c))
|
|
223
|
+
label = f"|{label}|" if names & SIGNED else f"|{label} - 1|" if names & ONE else label
|
|
224
|
+
reference, _, metabolite = c.split(" ", 1)[1].partition(" ")
|
|
225
|
+
labels[c] = " ".join(filter(None, [label, reference if several else "", metabolite]))
|
|
226
|
+
if by == "measure":
|
|
227
|
+
corners = scaled.rename(columns=labels)
|
|
228
|
+
else:
|
|
229
|
+
group = {c: aspect(_metric(c), analysis[c]) for c in scaled.columns}
|
|
230
|
+
corners = scaled.T.groupby(group).mean().T
|
|
231
|
+
corners = corners.rename(columns={a: a + "\n" + ", ".join(
|
|
232
|
+
labels[c] for c in scaled.columns if group[c] == a) for a in corners.columns})
|
|
233
|
+
return radar(corners, {c: (0, 1) for c in corners.columns}, ax)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
#************#
|
|
237
|
+
# aspect #
|
|
238
|
+
#************#
|
|
239
|
+
def aspect(metric: str, analysis: str) -> str:
|
|
240
|
+
"""The aspect a measure judges, as MRSValidate groups its analyses: the module of the
|
|
241
|
+
analysis that ran, or of the metric's own name; MRSuite's own errors (MAE, bias, MOSAE)
|
|
242
|
+
are errors."""
|
|
243
|
+
from mrsuite.benchmark.scoring import analyses
|
|
244
|
+
|
|
245
|
+
modules = {name.split(".")[1]: name.split(".")[0] for name in analyses()}
|
|
246
|
+
module = "errors" if metric in OWN else modules.get(analysis) or modules.get(metric, "errors")
|
|
247
|
+
return ASPECTS.get(module, module.replace("_", " ").capitalize())
|