doe-analyzer 0.3.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.
- doe_analyzer/__init__.py +14 -0
- doe_analyzer/__main__.py +3 -0
- doe_analyzer/api.py +311 -0
- doe_analyzer/core/__init__.py +1 -0
- doe_analyzer/core/calibration.py +191 -0
- doe_analyzer/core/clustering.py +144 -0
- doe_analyzer/core/columns.py +264 -0
- doe_analyzer/core/correlation.py +190 -0
- doe_analyzer/core/csvio.py +366 -0
- doe_analyzer/core/dataset.py +351 -0
- doe_analyzer/core/demo.py +155 -0
- doe_analyzer/core/derived.py +141 -0
- doe_analyzer/core/design.py +1337 -0
- doe_analyzer/core/doe_quality.py +196 -0
- doe_analyzer/core/embedding.py +142 -0
- doe_analyzer/core/encoding.py +275 -0
- doe_analyzer/core/filters.py +142 -0
- doe_analyzer/core/fmt.py +88 -0
- doe_analyzer/core/infill.py +336 -0
- doe_analyzer/core/metrics.py +105 -0
- doe_analyzer/core/mixture.py +297 -0
- doe_analyzer/core/optimize.py +358 -0
- doe_analyzer/core/outliers.py +114 -0
- doe_analyzer/core/pareto.py +101 -0
- doe_analyzer/core/pce.py +210 -0
- doe_analyzer/core/project.py +214 -0
- doe_analyzer/core/reliability.py +58 -0
- doe_analyzer/core/report.py +134 -0
- doe_analyzer/core/runfiles.py +240 -0
- doe_analyzer/core/scales.py +260 -0
- doe_analyzer/core/sensitivity.py +550 -0
- doe_analyzer/core/session.py +189 -0
- doe_analyzer/core/som.py +109 -0
- doe_analyzer/core/space.py +240 -0
- doe_analyzer/core/stats.py +95 -0
- doe_analyzer/core/surrogates.py +1068 -0
- doe_analyzer/core/tasks.py +34 -0
- doe_analyzer/core/tolerance.py +49 -0
- doe_analyzer/data/__init__.py +0 -0
- doe_analyzer/data/help/calibration.md +88 -0
- doe_analyzer/data/help/clustering.md +96 -0
- doe_analyzer/data/help/compare.md +68 -0
- doe_analyzer/data/help/correlation.md +98 -0
- doe_analyzer/data/help/data.md +144 -0
- doe_analyzer/data/help/distributions.md +101 -0
- doe_analyzer/data/help/doe_quality.md +167 -0
- doe_analyzer/data/help/embedding.md +91 -0
- doe_analyzer/data/help/nextruns.md +98 -0
- doe_analyzer/data/help/optimisation.md +102 -0
- doe_analyzer/data/help/outliers.md +108 -0
- doe_analyzer/data/help/overview.md +354 -0
- doe_analyzer/data/help/parallel.md +49 -0
- doe_analyzer/data/help/pareto.md +103 -0
- doe_analyzer/data/help/scatter.md +75 -0
- doe_analyzer/data/help/sensitivity.md +140 -0
- doe_analyzer/data/help/som.md +43 -0
- doe_analyzer/data/help/surrogates.md +183 -0
- doe_analyzer/data/help/whatif.md +141 -0
- doe_analyzer/data/icons/LICENSE +202 -0
- doe_analyzer/data/icons/add.svg +1 -0
- doe_analyzer/data/icons/add_chart.svg +1 -0
- doe_analyzer/data/icons/bar_chart.svg +1 -0
- doe_analyzer/data/icons/bubble_chart.svg +1 -0
- doe_analyzer/data/icons/check.svg +1 -0
- doe_analyzer/data/icons/chevron_left.svg +1 -0
- doe_analyzer/data/icons/close.svg +1 -0
- doe_analyzer/data/icons/code.svg +1 -0
- doe_analyzer/data/icons/compare.svg +1 -0
- doe_analyzer/data/icons/content_copy.svg +1 -0
- doe_analyzer/data/icons/dark_mode.svg +1 -0
- doe_analyzer/data/icons/delete.svg +1 -0
- doe_analyzer/data/icons/description.svg +1 -0
- doe_analyzer/data/icons/deselect.svg +1 -0
- doe_analyzer/data/icons/download.svg +1 -0
- doe_analyzer/data/icons/edit.svg +1 -0
- doe_analyzer/data/icons/expand_less.svg +1 -0
- doe_analyzer/data/icons/expand_more.svg +1 -0
- doe_analyzer/data/icons/filter_alt.svg +1 -0
- doe_analyzer/data/icons/filter_list.svg +1 -0
- doe_analyzer/data/icons/folder_open.svg +1 -0
- doe_analyzer/data/icons/function.svg +1 -0
- doe_analyzer/data/icons/grid_4x4.svg +1 -0
- doe_analyzer/data/icons/grid_on.svg +1 -0
- doe_analyzer/data/icons/grid_view.svg +1 -0
- doe_analyzer/data/icons/help.svg +1 -0
- doe_analyzer/data/icons/history.svg +1 -0
- doe_analyzer/data/icons/image.svg +1 -0
- doe_analyzer/data/icons/info.svg +1 -0
- doe_analyzer/data/icons/ink_highlighter.svg +1 -0
- doe_analyzer/data/icons/insights.svg +1 -0
- doe_analyzer/data/icons/instant_mix.svg +1 -0
- doe_analyzer/data/icons/left_panel_close.svg +1 -0
- doe_analyzer/data/icons/left_panel_open.svg +1 -0
- doe_analyzer/data/icons/light_mode.svg +1 -0
- doe_analyzer/data/icons/menu.svg +1 -0
- doe_analyzer/data/icons/menu_book.svg +1 -0
- doe_analyzer/data/icons/model_training.svg +1 -0
- doe_analyzer/data/icons/more_vert.svg +1 -0
- doe_analyzer/data/icons/palette.svg +1 -0
- doe_analyzer/data/icons/play_arrow.svg +1 -0
- doe_analyzer/data/icons/refresh.svg +1 -0
- doe_analyzer/data/icons/report.svg +1 -0
- doe_analyzer/data/icons/save.svg +1 -0
- doe_analyzer/data/icons/scatter_plot.svg +1 -0
- doe_analyzer/data/icons/science.svg +1 -0
- doe_analyzer/data/icons/settings.svg +1 -0
- doe_analyzer/data/icons/speed.svg +1 -0
- doe_analyzer/data/icons/stacked_line_chart.svg +1 -0
- doe_analyzer/data/icons/stop.svg +1 -0
- doe_analyzer/data/icons/table_chart.svg +1 -0
- doe_analyzer/data/icons/tune.svg +1 -0
- doe_analyzer/data/icons/upload_file.svg +1 -0
- doe_analyzer/data/icons/warning.svg +1 -0
- doe_analyzer/data/icons/workspaces.svg +1 -0
- doe_analyzer/figures/__init__.py +28 -0
- doe_analyzer/figures/axes.py +123 -0
- doe_analyzer/figures/calibration.py +430 -0
- doe_analyzer/figures/clustering.py +195 -0
- doe_analyzer/figures/colorscales.py +129 -0
- doe_analyzer/figures/common.py +336 -0
- doe_analyzer/figures/compare.py +363 -0
- doe_analyzer/figures/correlation.py +166 -0
- doe_analyzer/figures/distributions.py +242 -0
- doe_analyzer/figures/doe_quality.py +318 -0
- doe_analyzer/figures/embedding.py +170 -0
- doe_analyzer/figures/figjson.py +82 -0
- doe_analyzer/figures/nextruns.py +204 -0
- doe_analyzer/figures/optimisation.py +445 -0
- doe_analyzer/figures/options.py +176 -0
- doe_analyzer/figures/outliers.py +138 -0
- doe_analyzer/figures/parallel.py +334 -0
- doe_analyzer/figures/pareto.py +347 -0
- doe_analyzer/figures/scatter.py +319 -0
- doe_analyzer/figures/sensitivity.py +415 -0
- doe_analyzer/figures/som.py +188 -0
- doe_analyzer/figures/surrogates.py +456 -0
- doe_analyzer/figures/theme.py +126 -0
- doe_analyzer/figures/whatif.py +1425 -0
- doe_analyzer/gui/__init__.py +0 -0
- doe_analyzer/gui/app_window.py +1043 -0
- doe_analyzer/gui/colorscale_edit.py +202 -0
- doe_analyzer/gui/column_panel.py +444 -0
- doe_analyzer/gui/data_table.py +223 -0
- doe_analyzer/gui/design_dialog.py +262 -0
- doe_analyzer/gui/errors.py +174 -0
- doe_analyzer/gui/export_dialog.py +251 -0
- doe_analyzer/gui/exporter.py +256 -0
- doe_analyzer/gui/form_dialog.py +37 -0
- doe_analyzer/gui/formula_dialog.py +96 -0
- doe_analyzer/gui/help_view.py +129 -0
- doe_analyzer/gui/icons.py +62 -0
- doe_analyzer/gui/import_dialog.py +93 -0
- doe_analyzer/gui/nav.py +87 -0
- doe_analyzer/gui/pages/__init__.py +0 -0
- doe_analyzer/gui/pages/base.py +662 -0
- doe_analyzer/gui/pages/data_page.py +229 -0
- doe_analyzer/gui/pages/form.py +173 -0
- doe_analyzer/gui/pages/whatif_page.py +262 -0
- doe_analyzer/gui/plotview.py +437 -0
- doe_analyzer/gui/qss.py +182 -0
- doe_analyzer/gui/range_dialog.py +62 -0
- doe_analyzer/gui/session.py +25 -0
- doe_analyzer/gui/settings.py +87 -0
- doe_analyzer/gui/settings_dialog.py +133 -0
- doe_analyzer/gui/space_dialog.py +53 -0
- doe_analyzer/gui/tasks.py +122 -0
- doe_analyzer/gui/theming.py +86 -0
- doe_analyzer/gui/web/bridge.js +218 -0
- doe_analyzer/gui/web/host.html +16 -0
- doe_analyzer/gui/web/titles.js +109 -0
- doe_analyzer/gui/widgets.py +376 -0
- doe_analyzer/help.py +54 -0
- doe_analyzer/logs.py +170 -0
- doe_analyzer/main.py +165 -0
- doe_analyzer/script.py +251 -0
- doe_analyzer/tokens.py +61 -0
- doe_analyzer/web/__init__.py +0 -0
- doe_analyzer/web/controller.py +1418 -0
- doe_analyzer/web/shims/moocore.py +61 -0
- doe_analyzer/web/shims/stubs.py +78 -0
- doe_analyzer-0.3.0.dist-info/METADATA +207 -0
- doe_analyzer-0.3.0.dist-info/RECORD +187 -0
- doe_analyzer-0.3.0.dist-info/WHEEL +5 -0
- doe_analyzer-0.3.0.dist-info/entry_points.txt +5 -0
- doe_analyzer-0.3.0.dist-info/licenses/LICENSE +21 -0
- doe_analyzer-0.3.0.dist-info/licenses/doe_analyzer/data/icons/LICENSE +202 -0
- doe_analyzer-0.3.0.dist-info/top_level.txt +1 -0
doe_analyzer/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""DOE Analyzer — analyse design-of-experiments results from CSV."""
|
|
2
|
+
|
|
3
|
+
import warnings as _warnings
|
|
4
|
+
|
|
5
|
+
__version__ = "0.3.0"
|
|
6
|
+
|
|
7
|
+
# scikit-learn's forests warn about joblib's `delayed` on every fit when run
|
|
8
|
+
# from worker threads; it is noise for this application.
|
|
9
|
+
_warnings.filterwarnings("ignore", message=r".*sklearn\.utils\.parallel\.delayed.*", category=UserWarning)
|
|
10
|
+
# MinCovDet (robust Mahalanobis outliers) warns on every fit when the data are
|
|
11
|
+
# (nearly) singular, as with few discrete levels; the estimate is still used.
|
|
12
|
+
_warnings.filterwarnings("ignore", message=r"Determinant has increased", category=RuntimeWarning)
|
|
13
|
+
_warnings.filterwarnings("ignore", message=r"The covariance matrix associated to your dataset is not full rank",
|
|
14
|
+
category=UserWarning)
|
doe_analyzer/__main__.py
ADDED
doe_analyzer/api.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""DOE Analyzer in a script: the steps behind a plot, one function each.
|
|
2
|
+
|
|
3
|
+
Export plot as Python script… writes a script of these calls; they are kept
|
|
4
|
+
working across versions. Each step returns a new dataset (nothing changes in
|
|
5
|
+
place), so a study is built in order:
|
|
6
|
+
|
|
7
|
+
import doe_analyzer.api as da
|
|
8
|
+
|
|
9
|
+
ds = da.read_csv("study.csv")
|
|
10
|
+
ds = da.set_column(ds, "y", role="result", goal="min")
|
|
11
|
+
runs = da.runs(ds, [da.hide_failed()])
|
|
12
|
+
snap = da.snapshot(ds, runs)
|
|
13
|
+
opts = da.options(ds, "scatter", x="x1", y="y")
|
|
14
|
+
result = da.compute(snap, "scatter", opts)
|
|
15
|
+
fig = da.figure(snap, "scatter", "scatter", opts, result, da.publication_theme(font_pt=9))
|
|
16
|
+
da.export(fig, "scatter.png", width=160, height=100, unit="mm", dpi=300)
|
|
17
|
+
|
|
18
|
+
Pages and plots are named by their keys, as in the names Export current plot
|
|
19
|
+
gives its files ("surrogates_parity": page "surrogates", plot "parity").
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
# before numpy loads, as the app starts (doe_analyzer.main): the same speed, and the same numbers
|
|
26
|
+
for _var in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS"):
|
|
27
|
+
os.environ.setdefault(_var, "1")
|
|
28
|
+
|
|
29
|
+
from dataclasses import fields, replace # noqa: E402
|
|
30
|
+
from pathlib import Path # noqa: E402
|
|
31
|
+
|
|
32
|
+
import numpy as np # noqa: E402
|
|
33
|
+
|
|
34
|
+
from doe_analyzer.core import columns as C # noqa: E402
|
|
35
|
+
from doe_analyzer.core import csvio, derived, fmt, runfiles, scales # noqa: E402
|
|
36
|
+
from doe_analyzer.core.dataset import Dataset, Snapshot # noqa: E402
|
|
37
|
+
from doe_analyzer.core.filters import Filter, combined_mask, id_mask, row_mask # noqa: E402
|
|
38
|
+
from doe_analyzer.core.tasks import NULL_TOKEN # noqa: E402
|
|
39
|
+
|
|
40
|
+
COLUMN_SETTINGS = ("role", "vtype", "scale", "goal", "target", "constraint", "importance", "bounds")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------------------------------------ data
|
|
44
|
+
def demo(name: str) -> Dataset:
|
|
45
|
+
"""One of the demo datasets: branin, ishigami, zdt_constrained, mixed_categorical, large_20k."""
|
|
46
|
+
from doe_analyzer.core import demo as demos
|
|
47
|
+
if name not in demos.DEMOS:
|
|
48
|
+
raise ValueError(f"no demo dataset {name!r}; there are {', '.join(demos.DEMOS)}")
|
|
49
|
+
return demos.dataset(name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_csv(path, encoding: str | None = None, delimiter: str | None = None, decimal: str | None = None) -> Dataset:
|
|
53
|
+
"""The CSV with the column settings DOE Analyzer infers (and a mixture its parameters add up to); what is
|
|
54
|
+
not given is detected, as on opening it."""
|
|
55
|
+
from doe_analyzer.core import space
|
|
56
|
+
path = Path(path)
|
|
57
|
+
opts = csvio.sniff(path)
|
|
58
|
+
opts = csvio.CsvOptions(encoding or opts.encoding, delimiter or opts.delimiter, decimal or opts.decimal)
|
|
59
|
+
df, report, opts = csvio.read_csv(path, opts)
|
|
60
|
+
return space.with_detected(Dataset(df, C.infer_specs(df), str(path), opts, report))[0]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def set_column(ds: Dataset, name: str, **settings) -> Dataset:
|
|
64
|
+
"""A column's settings, as the Data page's column panel sets them:
|
|
65
|
+
|
|
66
|
+
* role: "param", "result", "id", "ignore" or "fidelity"
|
|
67
|
+
* vtype: "continuous", "discrete", "categorical" (or "image", "curve" for file names)
|
|
68
|
+
* scale: "linear", "log10" or ("symlog", C)
|
|
69
|
+
* goal: None, "min", "max" or "target" (with target, a number)
|
|
70
|
+
* constraint: None, ("<=", limit), (">=", limit) or ("between", lower, upper)
|
|
71
|
+
* importance: 1 to 5
|
|
72
|
+
* bounds: None or (lower, upper), a parameter's range in original units
|
|
73
|
+
"""
|
|
74
|
+
unknown = set(settings) - set(COLUMN_SETTINGS)
|
|
75
|
+
if unknown:
|
|
76
|
+
raise TypeError(f"no column setting {', '.join(sorted(unknown))}; there are {', '.join(COLUMN_SETTINGS)}")
|
|
77
|
+
if not ds.has(name):
|
|
78
|
+
raise ValueError(f"no column {name!r}")
|
|
79
|
+
spec = ds.spec(name)
|
|
80
|
+
kw = dict(settings)
|
|
81
|
+
if "role" in kw and kw["role"] not in C.ROLES:
|
|
82
|
+
raise ValueError(f"{name}: role is one of {', '.join(C.ROLES)}")
|
|
83
|
+
if "vtype" in kw:
|
|
84
|
+
if kw["vtype"] not in C.VTYPES + C.FILE_VTYPES:
|
|
85
|
+
raise ValueError(f"{name}: vtype is one of {', '.join(C.VTYPES + C.FILE_VTYPES)}")
|
|
86
|
+
# the levels follow the values, as the panel finds them
|
|
87
|
+
kw["levels"] = C.levels_of(ds.df[name], spec.numeric) if kw["vtype"] in ("discrete", "categorical") else None
|
|
88
|
+
if "scale" in kw:
|
|
89
|
+
kw["scale"] = _scale(kw["scale"])
|
|
90
|
+
if kw.get("goal") not in C.GOALS:
|
|
91
|
+
raise ValueError(f"{name}: goal is one of None, min, max, target")
|
|
92
|
+
if kw.get("target") is not None:
|
|
93
|
+
kw["target"] = float(kw["target"])
|
|
94
|
+
if kw.get("constraint"):
|
|
95
|
+
op, *limits = kw["constraint"]
|
|
96
|
+
if op not in C.OPS:
|
|
97
|
+
raise ValueError(f"{name}: a constraint's operator is one of {', '.join(C.OPS)}")
|
|
98
|
+
kw["constraint"] = (op, *(float(x) for x in limits))
|
|
99
|
+
if "importance" in kw:
|
|
100
|
+
kw["importance"] = int(kw["importance"])
|
|
101
|
+
if kw.get("bounds") is not None:
|
|
102
|
+
kw["bounds"] = (float(kw["bounds"][0]), float(kw["bounds"][1]))
|
|
103
|
+
return ds.with_spec(replace(spec, **kw))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _scale(value) -> scales.Scale:
|
|
107
|
+
if isinstance(value, str):
|
|
108
|
+
if value == "symlog":
|
|
109
|
+
raise ValueError('a symlog scale needs its C: scale=("symlog", C)')
|
|
110
|
+
return scales.Scale(value)
|
|
111
|
+
kind, c = value
|
|
112
|
+
return scales.Scale(kind, float(c))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def add_formula(ds: Dataset, name: str, expr: str) -> Dataset:
|
|
116
|
+
"""A column computed from the others ("Add derived column"): a result, on the scale its values suggest."""
|
|
117
|
+
values, _ = derived.evaluate(ds.df, expr)
|
|
118
|
+
return ds.with_specs(list(ds.specs) + [C.ColumnSpec(name, role="result", expr=expr,
|
|
119
|
+
scale=scales.suggest(values))])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def add_curve_match(ds: Dataset, name: str, column: str, x, y, source: str = "") -> Dataset:
|
|
123
|
+
"""A result to minimise: each run's RMSE from a measured curve (x, y) (Compare runs: Match a measured
|
|
124
|
+
curve…); ``column`` is the column of the runs' curve files, ``source`` the measured curve's name."""
|
|
125
|
+
spec = runfiles.match_spec(ds, column, x, y, source)
|
|
126
|
+
return ds.with_specs(list(ds.specs) + [replace(spec, name=name)])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def set_space(ds: Dataset, constraints: list[str]) -> Dataset:
|
|
130
|
+
"""Constraints between parameters, as typed in the Data page's Constraints… ("x1 + x2 <= 1")."""
|
|
131
|
+
return ds.with_space(list(constraints))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def set_network(ds: Dataset, layers=(64, 64), passes: int = 3000, more=()) -> Dataset:
|
|
135
|
+
"""The neural network's hidden layer widths, the passes a training may take, and the passes of each
|
|
136
|
+
round it was trained further (Surrogate models: Neural network…)."""
|
|
137
|
+
from doe_analyzer.core.surrogates import Network
|
|
138
|
+
return ds.with_network(Network(tuple(int(w) for w in layers), int(passes), tuple(int(p) for p in more)))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def set_digits(ds: Dataset, digits: int) -> Dataset:
|
|
142
|
+
"""Significant digits of the numbers the plots show (Settings); for every plot this script makes."""
|
|
143
|
+
fmt.DEFAULT_SIG = int(digits)
|
|
144
|
+
return ds.with_sig(int(digits))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------------------------------------ runs
|
|
148
|
+
def range_filter(column: str, lo: float, hi: float) -> Filter:
|
|
149
|
+
"""The runs with lo ≤ column ≤ hi (original units)."""
|
|
150
|
+
return Filter("range", column=column, lo=float(lo), hi=float(hi))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def levels_filter(column: str, levels) -> Filter:
|
|
154
|
+
"""The runs at these levels of a categorical column."""
|
|
155
|
+
return Filter("levels", column=column, levels=tuple(str(x) for x in levels))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def runs_filter(ids, keep: bool = True, by_row: bool = False) -> Filter:
|
|
159
|
+
"""Only these runs (``keep``), or every run but these; by run ID, or with ``by_row`` by row number in
|
|
160
|
+
the file (1 the first run)."""
|
|
161
|
+
return Filter("ids", ids=tuple(str(i) for i in ids), keep=keep, by_row=by_row)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def hide_failed() -> Filter:
|
|
165
|
+
"""The runs whose results are all there."""
|
|
166
|
+
return Filter("hide_failed")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def feasible_only() -> Filter:
|
|
170
|
+
"""The runs that meet every constraint."""
|
|
171
|
+
return Filter("feasible_only")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def runs(ds: Dataset, filters) -> np.ndarray:
|
|
175
|
+
"""The runs that pass every filter (a mask of the dataset's rows). A filter on a column the data do not
|
|
176
|
+
have is an error, not one that keeps every run."""
|
|
177
|
+
out = []
|
|
178
|
+
for f in filters:
|
|
179
|
+
if f.column is not None and not ds.has(f.column):
|
|
180
|
+
raise ValueError(f"no column {f.column!r}")
|
|
181
|
+
if f.kind == "levels" and ds.spec(f.column).numeric:
|
|
182
|
+
# numbers as the column's levels name them: levels_filter("layers", [1, 2]) is at 1.0 and 2.0
|
|
183
|
+
named = {v: str(v) for v in ds.levels(f.column)}
|
|
184
|
+
f = replace(f, levels=tuple(named.get(csvio.parse_number(x, "."), x) for x in f.levels))
|
|
185
|
+
out.append(f)
|
|
186
|
+
return combined_mask(ds, out)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def select(ds: Dataset, ids, by_row: bool = False) -> np.ndarray:
|
|
190
|
+
"""A selection: these runs, by run ID, or with ``by_row`` by row number in the file."""
|
|
191
|
+
ids = [str(i) for i in ids]
|
|
192
|
+
return row_mask(ds, ids) if by_row else id_mask(ds, ids)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def snapshot(ds: Dataset, runs=None, selected=None, selected_on: str | None = None) -> Snapshot:
|
|
196
|
+
"""What a page sees: the runs in play and, highlighted among them, the selection; ``selected_on``: the
|
|
197
|
+
page it was made on, which shows it as made there (a brush, a lasso) rather than highlighted."""
|
|
198
|
+
active = np.ones(ds.n, dtype=bool) if runs is None else np.asarray(runs, dtype=bool)
|
|
199
|
+
if selected is not None:
|
|
200
|
+
selected = np.asarray(selected, dtype=bool) & active
|
|
201
|
+
return Snapshot(ds, active, selected, selected_on if selected is not None else None)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------------------------------------ pages
|
|
205
|
+
def _page(page: str):
|
|
206
|
+
from doe_analyzer.figures import TOPICS
|
|
207
|
+
for t in TOPICS:
|
|
208
|
+
if t.KEY == page:
|
|
209
|
+
return t
|
|
210
|
+
raise ValueError(f"no page {page!r}; there are {', '.join(t.KEY for t in TOPICS)}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _plain(v):
|
|
214
|
+
if isinstance(v, (list, tuple)):
|
|
215
|
+
return [_plain(x) for x in v]
|
|
216
|
+
if isinstance(v, dict):
|
|
217
|
+
return {k: _plain(x) for k, x in v.items()}
|
|
218
|
+
return v
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def options(ds: Dataset, page: str, **values):
|
|
222
|
+
"""A page's options: the ones given, the page's defaults for the others. A value the page cannot take
|
|
223
|
+
here (a column the data do not have) is an error, not quietly replaced."""
|
|
224
|
+
t = _page(page)
|
|
225
|
+
names = [f.name for f in fields(t.Options)]
|
|
226
|
+
unknown = [k for k in values if k not in names]
|
|
227
|
+
if unknown:
|
|
228
|
+
raise TypeError(f"{page} has no option {', '.join(unknown)}; its options: {', '.join(names)}")
|
|
229
|
+
opts = t.options(ds, values)
|
|
230
|
+
for k, v in values.items():
|
|
231
|
+
got = getattr(opts, k)
|
|
232
|
+
if _plain(got) != _plain(v):
|
|
233
|
+
raise ValueError(f"{page}: {k}={v!r} cannot be used with these data (it would be {got!r})")
|
|
234
|
+
return opts
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def compute(snap: Snapshot, page: str, opts):
|
|
238
|
+
"""The page's computation (models fitted, an embedding, …): what its plots draw."""
|
|
239
|
+
return _page(page).compute(snap, opts, NULL_TOKEN)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def figure(snap: Snapshot, page: str, name: str, opts, result, theme) -> dict:
|
|
243
|
+
"""One plot of the page, as a plotly figure (a dict)."""
|
|
244
|
+
t = _page(page)
|
|
245
|
+
plots = [k for k, _ in t.FIGURES]
|
|
246
|
+
if name not in plots:
|
|
247
|
+
raise ValueError(f"{page} has no plot {name!r}; its plots: {', '.join(plots)}")
|
|
248
|
+
return t.build(snap, opts, result, theme, only={name})[name]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def publication_theme(font_pt: float = 9.0):
|
|
252
|
+
"""White, for print (the export's Publication theme)."""
|
|
253
|
+
from doe_analyzer.figures.theme import publication_theme as theme
|
|
254
|
+
return theme(font_pt * 96.0 / 72.0)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def dark_theme(font_pt: float = 9.0):
|
|
258
|
+
"""The export's Dark theme."""
|
|
259
|
+
from doe_analyzer.figures.theme import dark_export_theme
|
|
260
|
+
return dark_export_theme(font_pt * 96.0 / 72.0)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def ui_theme(seed: str, dark: bool = False, contrast: float = 0.0, font_pt: float = 9.0):
|
|
264
|
+
"""The app's look (the export's Current UI theme): its colour, light or dark, and contrast (Settings)."""
|
|
265
|
+
from doe_analyzer.figures.theme import ui_theme as theme
|
|
266
|
+
from doe_analyzer.tokens import scheme_tokens
|
|
267
|
+
return theme(scheme_tokens(seed, dark, contrast), dark, font_pt * 96.0 / 72.0)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def zoom(fig: dict, ranges: dict) -> dict:
|
|
271
|
+
"""The figure zoomed to these axis ranges ({"xaxis": [lo, hi], …}, in the plotted units)."""
|
|
272
|
+
from doe_analyzer.gui.exporter import apply_zoom
|
|
273
|
+
return apply_zoom(fig, ranges)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------------------------------------ image
|
|
277
|
+
def export(fig: dict, path, width: float = 160.0, height: float = 100.0, unit: str = "mm", dpi: int = 300) -> list[str]:
|
|
278
|
+
"""Write the figure as PNG, SVG or PDF (by the file's suffix), as Export current plot writes it, without a
|
|
279
|
+
window; ``unit`` is "mm", "in" or "px", ``dpi`` the PNG's. Returns warnings (a WebGL plot in an SVG)."""
|
|
280
|
+
path = Path(path)
|
|
281
|
+
kind = path.suffix.lower().lstrip(".")
|
|
282
|
+
if kind not in ("png", "svg", "pdf"):
|
|
283
|
+
raise ValueError(f"{path.name}: the image is a .png, .svg or .pdf file")
|
|
284
|
+
_qt()
|
|
285
|
+
from doe_analyzer.gui.exporter import ExportOptions, export_figure
|
|
286
|
+
return export_figure(fig, path, ExportOptions(fmt=kind, width=float(width), height=float(height), unit=unit,
|
|
287
|
+
dpi=int(dpi)))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _qt() -> None:
|
|
291
|
+
"""The Qt application the exporter draws in: offscreen, so a script needs no display."""
|
|
292
|
+
from PySide6.QtWidgets import QApplication
|
|
293
|
+
if QApplication.instance() is not None:
|
|
294
|
+
return
|
|
295
|
+
import atexit
|
|
296
|
+
# not the desktop's platform (WSL setups name xcb): that drew through the display and its GPU, and Qt 6.10's
|
|
297
|
+
# GPU thread crashed there as the script ended
|
|
298
|
+
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
|
299
|
+
# offscreen Qt warned of the GPU contexts it could not make (7 lines, every run) and draws all the same;
|
|
300
|
+
# a failed drawing raises ExportError. QtWebEngine said "Sandboxing disabled by user." every run (on Linux
|
|
301
|
+
# main.configure_webengine_env turns the sandbox off)
|
|
302
|
+
os.environ.setdefault("QT_LOGGING_RULES", "*.warning=false;default.info=false")
|
|
303
|
+
from doe_analyzer.main import prepare_qt
|
|
304
|
+
prepare_qt(["doe_analyzer"], gl="auto")
|
|
305
|
+
|
|
306
|
+
def close():
|
|
307
|
+
from doe_analyzer.gui.exporter import shutdown_renderer
|
|
308
|
+
from doe_analyzer.gui.plotview import shutdown
|
|
309
|
+
shutdown_renderer()
|
|
310
|
+
shutdown()
|
|
311
|
+
atexit.register(close)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure computation. Nothing in this package imports Qt."""
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Bayesian calibration: the parameters that the runs (simulations) vary but an experiment cannot set,
|
|
2
|
+
inferred from what experiments measured.
|
|
3
|
+
|
|
4
|
+
Each experiment is a row of a measurements CSV: the parameters known in it (a column each, named as in the
|
|
5
|
+
runs), the results measured (a column each) and, optionally, each result's standard deviation (a column
|
|
6
|
+
``<result>_sd``) and a measured curve (a file name in the column of the runs' curve files). The parameters
|
|
7
|
+
without a column are calibrated: one value for all the experiments, with a uniform prior between their
|
|
8
|
+
bounds. Each measured value is the result's surrogate at the experiment's known settings and the calibrated
|
|
9
|
+
values, plus Gaussian errors, the measurement's and the surrogate's own (its σ), independent (no model
|
|
10
|
+
discrepancy, Kennedy and O'Hagan's δ, is fitted); a curve enters by its coefficients on the shapes (POD modes)
|
|
11
|
+
of the runs' curves (Higdon et al., 2008). Everything is in each result's scale space.
|
|
12
|
+
|
|
13
|
+
The posterior is sampled by the affine-invariant ensemble sampler of Goodman and Weare (2010) (the stretch
|
|
14
|
+
move, as in emcee), its walkers started around the most probable point that differential evolution finds; the
|
|
15
|
+
first half of the steps is dropped as burn-in. The integrated autocorrelation time (Sokal's window on the
|
|
16
|
+
walkers' mean autocorrelation) gives the effective number of independent samples.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from doe_analyzer.core import space
|
|
26
|
+
from doe_analyzer.core.tasks import NULL_TOKEN, Cancelled, Token
|
|
27
|
+
|
|
28
|
+
SD_SUFFIX = "_sd"
|
|
29
|
+
STRETCH = 2.0 # the stretch move's scale a
|
|
30
|
+
MIN_WALKERS = 32
|
|
31
|
+
KEEP = 4000 # posterior samples kept (thinned)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def table_of(df: pd.DataFrame, ds, curve: str | None = None) -> tuple[dict, str]:
|
|
35
|
+
"""(the experiments as stored with the page: {column: values} for the CSV's columns that are parameters,
|
|
36
|
+
numeric results, their ``_sd`` and the curve column; the message that says what was read). ValueError when
|
|
37
|
+
none measures a result or every parameter is known."""
|
|
38
|
+
params = [p for p in ds.params if not ds.spec(p).is_computed]
|
|
39
|
+
results = [r for r in ds.numeric(ds.results) if r in df.columns]
|
|
40
|
+
if not results and not (curve and curve in df.columns):
|
|
41
|
+
raise ValueError("the CSV has no column named as a numeric result of the runs: nothing measured")
|
|
42
|
+
known = [p for p in params if p in df.columns]
|
|
43
|
+
if len(known) == len(params):
|
|
44
|
+
raise ValueError("the CSV gives every parameter: nothing is left to calibrate")
|
|
45
|
+
mix = space.mixture_in(ds)
|
|
46
|
+
missing = [c for c in (mix.components if mix is not None else ()) if c not in known]
|
|
47
|
+
if missing: # one calibrated alone would take its share from the shares the experiments set
|
|
48
|
+
raise ValueError(f"{', '.join(missing)} {'is a component' if len(missing) == 1 else 'are components'} of "
|
|
49
|
+
f"{mix.describe()}: give each experiment's blend, a column for every component")
|
|
50
|
+
keep = known + results + [r + SD_SUFFIX for r in results if r + SD_SUFFIX in df.columns] + \
|
|
51
|
+
([curve] if curve and curve in df.columns else [])
|
|
52
|
+
out = {}
|
|
53
|
+
for c in keep:
|
|
54
|
+
v = df[c]
|
|
55
|
+
if c == curve:
|
|
56
|
+
out[c] = ["" if x is None or (isinstance(x, float) and np.isnan(x)) else str(x) for x in v]
|
|
57
|
+
elif ds.has(c) and ds.spec(c).is_categorical:
|
|
58
|
+
out[c] = [None if x is None or (isinstance(x, float) and np.isnan(x)) else str(x) for x in v]
|
|
59
|
+
else: # None where not measured: a project is strict JSON, without NaN
|
|
60
|
+
out[c] = [float(x) if np.isfinite(x) else None for x in pd.to_numeric(v, errors="coerce")]
|
|
61
|
+
free = [p for p in params if p not in known]
|
|
62
|
+
measured = results + ([f"the curves of {curve}"] if curve in out else [])
|
|
63
|
+
return out, (f"{len(df):,} experiment{'s' if len(df) != 1 else ''}: {', '.join(measured)} measured"
|
|
64
|
+
+ (f" at known {', '.join(known)}" if known else "") + f"; calibrating {', '.join(free)}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Problem:
|
|
69
|
+
"""The posterior of the calibrated parameters' unit coordinates ``theta`` (d,): ``logp(Theta)`` for rows
|
|
70
|
+
of them (walkers × d)."""
|
|
71
|
+
enc: object # the surrogates' encoder (tied: a formula parameter follows the others)
|
|
72
|
+
free: list # indices of the calibrated parameters among enc.codes
|
|
73
|
+
base: np.ndarray # (experiments, enc.dim): the known settings' unit coordinates
|
|
74
|
+
terms: list # (surrogate, observed (experiments,), measurement σ (experiments,)) in scale space
|
|
75
|
+
curves: list = field(default_factory=list) # (coefficient surrogates, observed (experiments, modes), σ)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def d(self) -> int:
|
|
79
|
+
return len(self.free)
|
|
80
|
+
|
|
81
|
+
def points(self, Theta: np.ndarray) -> np.ndarray:
|
|
82
|
+
"""Every experiment at every row of ``Theta``: (rows × experiments, dim), row-major."""
|
|
83
|
+
Theta = np.atleast_2d(Theta)
|
|
84
|
+
U = np.repeat(self.base[None, :, :], len(Theta), axis=0)
|
|
85
|
+
U[:, :, self.free] = Theta[:, None, :]
|
|
86
|
+
return U.reshape(-1, self.base.shape[1])
|
|
87
|
+
|
|
88
|
+
def logp(self, Theta: np.ndarray) -> np.ndarray:
|
|
89
|
+
Theta = np.atleast_2d(Theta)
|
|
90
|
+
inside = ((Theta >= 0) & (Theta <= 1)).all(axis=1)
|
|
91
|
+
out = np.full(len(Theta), -np.inf)
|
|
92
|
+
if not inside.any():
|
|
93
|
+
return out
|
|
94
|
+
U = self.points(np.clip(Theta[inside], 0.0, 1.0))
|
|
95
|
+
m = int(inside.sum())
|
|
96
|
+
total = np.zeros(m)
|
|
97
|
+
for sur, obs, sd in self.terms:
|
|
98
|
+
total += _gauss(sur, U, m, obs, sd)
|
|
99
|
+
for surs, obs, sd in self.curves:
|
|
100
|
+
for k, sur in enumerate(surs):
|
|
101
|
+
total += _gauss(sur, U, m, obs[:, k], sd)
|
|
102
|
+
out[inside] = total
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _gauss(sur, U: np.ndarray, m: int, obs: np.ndarray, sd: np.ndarray) -> np.ndarray:
|
|
107
|
+
"""Σ over the experiments that measured it of log N(observed; μ, σ_meas² + σ_model²), for m walkers."""
|
|
108
|
+
mean, std = sur.predict_unit(U)
|
|
109
|
+
mean, std = mean.reshape(m, -1), std.reshape(m, -1)
|
|
110
|
+
ok = np.isfinite(obs)
|
|
111
|
+
v = sd[ok] ** 2 + std[:, ok] ** 2
|
|
112
|
+
return -0.5 * (((obs[ok] - mean[:, ok]) ** 2) / v + np.log(2 * np.pi * v)).sum(axis=1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def most_probable(prob: Problem, token: Token = NULL_TOKEN, seed: int = 0) -> np.ndarray:
|
|
116
|
+
"""The unit coordinates of the highest posterior density differential evolution finds (no polish)."""
|
|
117
|
+
from scipy.optimize import differential_evolution
|
|
118
|
+
gens = []
|
|
119
|
+
|
|
120
|
+
def callback(intermediate_result):
|
|
121
|
+
gens.append(1)
|
|
122
|
+
token.progress(0.2 * min(len(gens) / 60, 1.0), "finding the most probable values")
|
|
123
|
+
|
|
124
|
+
res = differential_evolution(lambda T: -prob.logp(np.atleast_2d(T.T)), [(0.0, 1.0)] * prob.d, vectorized=True,
|
|
125
|
+
seed=seed, maxiter=60, popsize=15, tol=1e-8, polish=False, updating="deferred",
|
|
126
|
+
callback=callback)
|
|
127
|
+
return np.clip(res.x, 0.0, 1.0)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sample(prob: Problem, start: np.ndarray, steps: int, token: Token = NULL_TOKEN, seed: int = 0) -> dict:
|
|
131
|
+
"""The ensemble sampler's chain from a ball around ``start``: {"chain": (steps, walkers, d), "logp":
|
|
132
|
+
(steps, walkers), "accepted": the share of moves accepted}. Each step moves one half of the walkers, each
|
|
133
|
+
along the line to a walker of the other half by a factor z with density ∝ 1/√z on [1/a, a], accepted
|
|
134
|
+
with probability min(1, z^(d−1) p(new)/p(old))."""
|
|
135
|
+
rng = np.random.default_rng(seed)
|
|
136
|
+
d = prob.d
|
|
137
|
+
W = max(MIN_WALKERS, 4 * d + (4 * d) % 2)
|
|
138
|
+
X = np.clip(start + 1e-3 * rng.standard_normal((W, d)), 1e-6, 1 - 1e-6)
|
|
139
|
+
lp = prob.logp(X)
|
|
140
|
+
chain, logp = np.empty((steps, W, d)), np.empty((steps, W))
|
|
141
|
+
accepted = 0
|
|
142
|
+
halves = (np.arange(0, W, 2), np.arange(1, W, 2))
|
|
143
|
+
for s in range(steps):
|
|
144
|
+
if s % 20 == 0:
|
|
145
|
+
if token.cancelled:
|
|
146
|
+
raise Cancelled()
|
|
147
|
+
token.progress(0.2 + 0.75 * s / steps, f"sampling: step {s:,} of {steps:,}")
|
|
148
|
+
for a, b in (halves, halves[::-1]):
|
|
149
|
+
z = ((STRETCH - 1) * rng.random(len(a)) + 1) ** 2 / STRETCH
|
|
150
|
+
other = X[rng.choice(b, len(a))]
|
|
151
|
+
new = other + z[:, None] * (X[a] - other)
|
|
152
|
+
lp_new = prob.logp(new)
|
|
153
|
+
with np.errstate(invalid="ignore", over="ignore"):
|
|
154
|
+
ratio = (d - 1) * np.log(z) + lp_new - lp[a]
|
|
155
|
+
ok = np.log(rng.random(len(a))) < ratio
|
|
156
|
+
X[a[ok]], lp[a[ok]] = new[ok], lp_new[ok]
|
|
157
|
+
accepted += int(ok.sum())
|
|
158
|
+
chain[s], logp[s] = X, lp
|
|
159
|
+
return {"chain": chain, "logp": logp, "accepted": accepted / (steps * W)}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def autocorr_time(chain: np.ndarray, c: float = 5.0) -> np.ndarray:
|
|
163
|
+
"""Integrated autocorrelation time per parameter of (steps, walkers, d): the walkers' mean
|
|
164
|
+
autocorrelation summed up to Sokal's window, the first M with M ≥ c τ(M) (as emcee does)."""
|
|
165
|
+
n = chain.shape[0]
|
|
166
|
+
out = np.empty(chain.shape[2])
|
|
167
|
+
size = int(2 ** np.ceil(np.log2(2 * n)))
|
|
168
|
+
for j in range(chain.shape[2]):
|
|
169
|
+
x = chain[:, :, j] - chain[:, :, j].mean(axis=0)
|
|
170
|
+
f = np.fft.rfft(x, n=size, axis=0)
|
|
171
|
+
acf = np.fft.irfft(f * np.conj(f), n=size, axis=0)[:n].mean(axis=1)
|
|
172
|
+
if not acf[0] > 0:
|
|
173
|
+
out[j] = np.inf
|
|
174
|
+
continue
|
|
175
|
+
taus = 2.0 * np.cumsum(acf / acf[0]) - 1.0
|
|
176
|
+
m = np.arange(n) < c * taus
|
|
177
|
+
out[j] = taus[np.argmin(m)] if not m.all() else taus[-1]
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def summary(chain: np.ndarray, logp: np.ndarray, seed: int = 0) -> dict:
|
|
182
|
+
"""The second half of the chain: thinned samples (at most KEEP), the highest-density sample, τ and the
|
|
183
|
+
effective sample size per parameter."""
|
|
184
|
+
half = chain[chain.shape[0] // 2:]
|
|
185
|
+
lp = logp[logp.shape[0] // 2:]
|
|
186
|
+
flat = half.reshape(-1, half.shape[2])
|
|
187
|
+
tau = autocorr_time(half)
|
|
188
|
+
best = flat[int(np.argmax(lp.reshape(-1)))]
|
|
189
|
+
idx = np.random.default_rng(seed).choice(len(flat), min(KEEP, len(flat)), replace=False)
|
|
190
|
+
return {"samples": flat[np.sort(idx)], "best": best, "tau": tau,
|
|
191
|
+
"ess": half.shape[0] * half.shape[1] / np.maximum(tau, 1.0), "steps_kept": half.shape[0]}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Clustering of runs on standardised scale-space features."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from doe_analyzer.core.tasks import NULL_TOKEN, Token
|
|
10
|
+
|
|
11
|
+
METHODS = {"kmeans": "k-means", "gmm": "Gaussian mixture", "agglomerative": "Agglomerative (Ward)",
|
|
12
|
+
"hdbscan": "HDBSCAN (density)", "dbscan": "DBSCAN (density)"}
|
|
13
|
+
AGGLO_MAX_ROWS = 5000
|
|
14
|
+
SILHOUETTE_SAMPLE = 5000
|
|
15
|
+
DENDRO_ROWS = 1500
|
|
16
|
+
THREADED_ROWS = 5000 # above it the Gaussian mixtures of a sweep are fitted on threads
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def silhouette(X: np.ndarray, labels: np.ndarray, seed: int = 0) -> float:
|
|
20
|
+
from sklearn.metrics import silhouette_score
|
|
21
|
+
ok = labels >= 0
|
|
22
|
+
if len(set(labels[ok])) < 2:
|
|
23
|
+
return float("nan")
|
|
24
|
+
n = min(SILHOUETTE_SAMPLE, int(ok.sum()))
|
|
25
|
+
# n_jobs: the distances in parallel blocks (threads), the same values; 1.7x faster on 5 000
|
|
26
|
+
# runs, slower below about 2 000, where starting the threads costs more than they save
|
|
27
|
+
return float(silhouette_score(X[ok], labels[ok], sample_size=n, random_state=seed,
|
|
28
|
+
n_jobs=-1 if n > 2000 else None))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _OneTree:
|
|
32
|
+
"""``memory`` for AgglomerativeClustering (joblib.Memory's ``cache`` interface):
|
|
33
|
+
without connectivity it builds the full Ward tree whatever k is, and only the
|
|
34
|
+
cut depends on k, so a sweep over k on the same runs builds it once."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.tree = None
|
|
38
|
+
|
|
39
|
+
def cache(self, build):
|
|
40
|
+
def cached(*args, **kwargs):
|
|
41
|
+
if self.tree is None:
|
|
42
|
+
self.tree = build(*args, **kwargs)
|
|
43
|
+
return self.tree
|
|
44
|
+
return cached
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sweep(method: str, X: np.ndarray, kmax: int = 10, token: Token = NULL_TOKEN, seed: int = 0) -> dict:
|
|
48
|
+
"""Quality per k: inertia and silhouette (k-means), BIC (GMM), silhouette (Ward);
|
|
49
|
+
the GMM and Ward labels per k, which ``cluster`` reuses."""
|
|
50
|
+
from sklearn.cluster import AgglomerativeClustering, KMeans
|
|
51
|
+
from sklearn.mixture import GaussianMixture
|
|
52
|
+
ks = list(range(2, max(3, min(kmax, X.shape[0] - 1)) + 1))
|
|
53
|
+
out = {"k": ks, "silhouette": [], "inertia": [], "bic": [], "labels": []}
|
|
54
|
+
Xa = X if X.shape[0] <= AGGLO_MAX_ROWS else X[np.random.default_rng(seed).choice(X.shape[0], AGGLO_MAX_ROWS, replace=False)]
|
|
55
|
+
tree = _OneTree()
|
|
56
|
+
if method == "gmm":
|
|
57
|
+
def fit(k):
|
|
58
|
+
token.check()
|
|
59
|
+
g = GaussianMixture(k, random_state=seed).fit(X)
|
|
60
|
+
return float(g.bic(X)), g.predict(X)
|
|
61
|
+
# independent fits, mostly BLAS (without the GIL): on threads 20 000 runs took 7 s instead of
|
|
62
|
+
# 17 s, the same labels; at a few thousand runs starting the threads costs what they save
|
|
63
|
+
workers = min(len(ks), os.cpu_count() or 1) if X.shape[0] > THREADED_ROWS else 1
|
|
64
|
+
with ThreadPoolExecutor(workers) as pool:
|
|
65
|
+
for i, (bic, labels) in enumerate(pool.map(fit, ks)):
|
|
66
|
+
token.progress(i / len(ks), f"k = {ks[i]}")
|
|
67
|
+
out["bic"].append(bic)
|
|
68
|
+
out["labels"].append(labels)
|
|
69
|
+
out["silhouette"].append(silhouette(X, labels, seed))
|
|
70
|
+
return out
|
|
71
|
+
for i, k in enumerate(ks):
|
|
72
|
+
token.progress(i / len(ks), f"k = {k}")
|
|
73
|
+
if method == "kmeans":
|
|
74
|
+
km = KMeans(k, n_init=4, random_state=seed).fit(X)
|
|
75
|
+
out["inertia"].append(float(km.inertia_))
|
|
76
|
+
out["silhouette"].append(silhouette(X, km.labels_, seed))
|
|
77
|
+
elif method == "agglomerative":
|
|
78
|
+
out["labels"].append(AgglomerativeClustering(k, memory=tree).fit_predict(Xa))
|
|
79
|
+
out["silhouette"].append(silhouette(Xa, out["labels"][-1], seed))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def best_k(sw: dict, method: str) -> int:
|
|
84
|
+
if method == "gmm" and sw["bic"]:
|
|
85
|
+
return sw["k"][int(np.argmin(sw["bic"]))]
|
|
86
|
+
sil = np.asarray(sw["silhouette"], dtype=float)
|
|
87
|
+
if sil.size and np.isfinite(sil).any():
|
|
88
|
+
return sw["k"][int(np.nanargmax(sil))]
|
|
89
|
+
return 3
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cluster(method: str, X: np.ndarray, k: int, min_size: int = 10, seed: int = 0,
|
|
93
|
+
sweep: dict | None = None) -> np.ndarray:
|
|
94
|
+
"""Labels (-1 = noise for density methods). A ``sweep`` of the same method, runs and
|
|
95
|
+
seed already holds the Gaussian mixture and Ward labels of its k."""
|
|
96
|
+
from sklearn.cluster import DBSCAN, HDBSCAN, AgglomerativeClustering, KMeans
|
|
97
|
+
from sklearn.mixture import GaussianMixture
|
|
98
|
+
swept = sweep["labels"][sweep["k"].index(k)] if sweep and sweep["labels"] and k in sweep["k"] else None
|
|
99
|
+
if method == "kmeans":
|
|
100
|
+
return KMeans(k, n_init=8, random_state=seed).fit_predict(X)
|
|
101
|
+
if method == "gmm":
|
|
102
|
+
return swept if swept is not None else GaussianMixture(k, random_state=seed).fit(X).predict(X)
|
|
103
|
+
if method == "agglomerative":
|
|
104
|
+
if X.shape[0] <= AGGLO_MAX_ROWS:
|
|
105
|
+
return swept if swept is not None else AgglomerativeClustering(k).fit_predict(X)
|
|
106
|
+
# fit on a subsample (the sweep's), assign the rest to the nearest cluster centroid
|
|
107
|
+
idx = np.random.default_rng(seed).choice(X.shape[0], AGGLO_MAX_ROWS, replace=False)
|
|
108
|
+
lab = swept if swept is not None else AgglomerativeClustering(k).fit_predict(X[idx])
|
|
109
|
+
cent = np.stack([X[idx][lab == c].mean(axis=0) for c in range(k)])
|
|
110
|
+
out = np.linalg.norm(X[:, None, :] - cent[None], axis=2).argmin(axis=1)
|
|
111
|
+
out[idx] = lab # the fitted runs keep Ward's clusters (the sweep's silhouettes are of these)
|
|
112
|
+
return out
|
|
113
|
+
if method == "hdbscan":
|
|
114
|
+
# scikit-learn refuses a size above the run count; then every run is noise anyway
|
|
115
|
+
return HDBSCAN(min_cluster_size=max(min(min_size, X.shape[0]), 2)).fit_predict(X)
|
|
116
|
+
if method == "dbscan":
|
|
117
|
+
from scipy.spatial import cKDTree
|
|
118
|
+
# the run itself counts toward Minimum cluster size, as in DBSCAN's min_samples: its
|
|
119
|
+
# (min_size − 1)-th other run (one further merged two groups of exactly that size)
|
|
120
|
+
d, _ = cKDTree(X).query(X, k=max(min(min_size, X.shape[0]), 2), workers=-1)
|
|
121
|
+
# 0 when most runs are exact replicates; DBSCAN needs eps > 0, and the
|
|
122
|
+
# smallest one groups the replicates
|
|
123
|
+
eps = max(float(np.quantile(d[:, -1], 0.9)), np.finfo(float).tiny)
|
|
124
|
+
# n_jobs: the neighbourhood queries in parallel (threads), the same result; 2.5x faster
|
|
125
|
+
# on 2 000 runs and more, while on a few hundred starting the threads costs more
|
|
126
|
+
return DBSCAN(eps=eps, min_samples=min_size, n_jobs=-1 if X.shape[0] > 1000 else None).fit_predict(X)
|
|
127
|
+
raise ValueError(method)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def profiles(X: np.ndarray, labels: np.ndarray) -> tuple[list[int], np.ndarray, np.ndarray]:
|
|
131
|
+
"""(cluster ids, mean standardised value per cluster × feature, sizes)."""
|
|
132
|
+
ids = sorted(int(c) for c in set(labels.tolist()) if c >= 0)
|
|
133
|
+
M = np.stack([X[labels == c].mean(axis=0) for c in ids]) if ids else np.zeros((0, X.shape[1]))
|
|
134
|
+
n = np.array([(labels == c).sum() for c in ids])
|
|
135
|
+
return ids, M, n
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def dendrogram(X: np.ndarray, seed: int = 0) -> dict:
|
|
139
|
+
"""scipy dendrogram coordinates of a Ward linkage (subsampled)."""
|
|
140
|
+
from scipy.cluster.hierarchy import dendrogram as dg, linkage
|
|
141
|
+
if X.shape[0] > DENDRO_ROWS:
|
|
142
|
+
X = X[np.random.default_rng(seed).choice(X.shape[0], DENDRO_ROWS, replace=False)]
|
|
143
|
+
Z = linkage(X, method="ward")
|
|
144
|
+
return dg(Z, no_plot=True, truncate_mode="lastp", p=40)
|