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.
Files changed (187) hide show
  1. doe_analyzer/__init__.py +14 -0
  2. doe_analyzer/__main__.py +3 -0
  3. doe_analyzer/api.py +311 -0
  4. doe_analyzer/core/__init__.py +1 -0
  5. doe_analyzer/core/calibration.py +191 -0
  6. doe_analyzer/core/clustering.py +144 -0
  7. doe_analyzer/core/columns.py +264 -0
  8. doe_analyzer/core/correlation.py +190 -0
  9. doe_analyzer/core/csvio.py +366 -0
  10. doe_analyzer/core/dataset.py +351 -0
  11. doe_analyzer/core/demo.py +155 -0
  12. doe_analyzer/core/derived.py +141 -0
  13. doe_analyzer/core/design.py +1337 -0
  14. doe_analyzer/core/doe_quality.py +196 -0
  15. doe_analyzer/core/embedding.py +142 -0
  16. doe_analyzer/core/encoding.py +275 -0
  17. doe_analyzer/core/filters.py +142 -0
  18. doe_analyzer/core/fmt.py +88 -0
  19. doe_analyzer/core/infill.py +336 -0
  20. doe_analyzer/core/metrics.py +105 -0
  21. doe_analyzer/core/mixture.py +297 -0
  22. doe_analyzer/core/optimize.py +358 -0
  23. doe_analyzer/core/outliers.py +114 -0
  24. doe_analyzer/core/pareto.py +101 -0
  25. doe_analyzer/core/pce.py +210 -0
  26. doe_analyzer/core/project.py +214 -0
  27. doe_analyzer/core/reliability.py +58 -0
  28. doe_analyzer/core/report.py +134 -0
  29. doe_analyzer/core/runfiles.py +240 -0
  30. doe_analyzer/core/scales.py +260 -0
  31. doe_analyzer/core/sensitivity.py +550 -0
  32. doe_analyzer/core/session.py +189 -0
  33. doe_analyzer/core/som.py +109 -0
  34. doe_analyzer/core/space.py +240 -0
  35. doe_analyzer/core/stats.py +95 -0
  36. doe_analyzer/core/surrogates.py +1068 -0
  37. doe_analyzer/core/tasks.py +34 -0
  38. doe_analyzer/core/tolerance.py +49 -0
  39. doe_analyzer/data/__init__.py +0 -0
  40. doe_analyzer/data/help/calibration.md +88 -0
  41. doe_analyzer/data/help/clustering.md +96 -0
  42. doe_analyzer/data/help/compare.md +68 -0
  43. doe_analyzer/data/help/correlation.md +98 -0
  44. doe_analyzer/data/help/data.md +144 -0
  45. doe_analyzer/data/help/distributions.md +101 -0
  46. doe_analyzer/data/help/doe_quality.md +167 -0
  47. doe_analyzer/data/help/embedding.md +91 -0
  48. doe_analyzer/data/help/nextruns.md +98 -0
  49. doe_analyzer/data/help/optimisation.md +102 -0
  50. doe_analyzer/data/help/outliers.md +108 -0
  51. doe_analyzer/data/help/overview.md +354 -0
  52. doe_analyzer/data/help/parallel.md +49 -0
  53. doe_analyzer/data/help/pareto.md +103 -0
  54. doe_analyzer/data/help/scatter.md +75 -0
  55. doe_analyzer/data/help/sensitivity.md +140 -0
  56. doe_analyzer/data/help/som.md +43 -0
  57. doe_analyzer/data/help/surrogates.md +183 -0
  58. doe_analyzer/data/help/whatif.md +141 -0
  59. doe_analyzer/data/icons/LICENSE +202 -0
  60. doe_analyzer/data/icons/add.svg +1 -0
  61. doe_analyzer/data/icons/add_chart.svg +1 -0
  62. doe_analyzer/data/icons/bar_chart.svg +1 -0
  63. doe_analyzer/data/icons/bubble_chart.svg +1 -0
  64. doe_analyzer/data/icons/check.svg +1 -0
  65. doe_analyzer/data/icons/chevron_left.svg +1 -0
  66. doe_analyzer/data/icons/close.svg +1 -0
  67. doe_analyzer/data/icons/code.svg +1 -0
  68. doe_analyzer/data/icons/compare.svg +1 -0
  69. doe_analyzer/data/icons/content_copy.svg +1 -0
  70. doe_analyzer/data/icons/dark_mode.svg +1 -0
  71. doe_analyzer/data/icons/delete.svg +1 -0
  72. doe_analyzer/data/icons/description.svg +1 -0
  73. doe_analyzer/data/icons/deselect.svg +1 -0
  74. doe_analyzer/data/icons/download.svg +1 -0
  75. doe_analyzer/data/icons/edit.svg +1 -0
  76. doe_analyzer/data/icons/expand_less.svg +1 -0
  77. doe_analyzer/data/icons/expand_more.svg +1 -0
  78. doe_analyzer/data/icons/filter_alt.svg +1 -0
  79. doe_analyzer/data/icons/filter_list.svg +1 -0
  80. doe_analyzer/data/icons/folder_open.svg +1 -0
  81. doe_analyzer/data/icons/function.svg +1 -0
  82. doe_analyzer/data/icons/grid_4x4.svg +1 -0
  83. doe_analyzer/data/icons/grid_on.svg +1 -0
  84. doe_analyzer/data/icons/grid_view.svg +1 -0
  85. doe_analyzer/data/icons/help.svg +1 -0
  86. doe_analyzer/data/icons/history.svg +1 -0
  87. doe_analyzer/data/icons/image.svg +1 -0
  88. doe_analyzer/data/icons/info.svg +1 -0
  89. doe_analyzer/data/icons/ink_highlighter.svg +1 -0
  90. doe_analyzer/data/icons/insights.svg +1 -0
  91. doe_analyzer/data/icons/instant_mix.svg +1 -0
  92. doe_analyzer/data/icons/left_panel_close.svg +1 -0
  93. doe_analyzer/data/icons/left_panel_open.svg +1 -0
  94. doe_analyzer/data/icons/light_mode.svg +1 -0
  95. doe_analyzer/data/icons/menu.svg +1 -0
  96. doe_analyzer/data/icons/menu_book.svg +1 -0
  97. doe_analyzer/data/icons/model_training.svg +1 -0
  98. doe_analyzer/data/icons/more_vert.svg +1 -0
  99. doe_analyzer/data/icons/palette.svg +1 -0
  100. doe_analyzer/data/icons/play_arrow.svg +1 -0
  101. doe_analyzer/data/icons/refresh.svg +1 -0
  102. doe_analyzer/data/icons/report.svg +1 -0
  103. doe_analyzer/data/icons/save.svg +1 -0
  104. doe_analyzer/data/icons/scatter_plot.svg +1 -0
  105. doe_analyzer/data/icons/science.svg +1 -0
  106. doe_analyzer/data/icons/settings.svg +1 -0
  107. doe_analyzer/data/icons/speed.svg +1 -0
  108. doe_analyzer/data/icons/stacked_line_chart.svg +1 -0
  109. doe_analyzer/data/icons/stop.svg +1 -0
  110. doe_analyzer/data/icons/table_chart.svg +1 -0
  111. doe_analyzer/data/icons/tune.svg +1 -0
  112. doe_analyzer/data/icons/upload_file.svg +1 -0
  113. doe_analyzer/data/icons/warning.svg +1 -0
  114. doe_analyzer/data/icons/workspaces.svg +1 -0
  115. doe_analyzer/figures/__init__.py +28 -0
  116. doe_analyzer/figures/axes.py +123 -0
  117. doe_analyzer/figures/calibration.py +430 -0
  118. doe_analyzer/figures/clustering.py +195 -0
  119. doe_analyzer/figures/colorscales.py +129 -0
  120. doe_analyzer/figures/common.py +336 -0
  121. doe_analyzer/figures/compare.py +363 -0
  122. doe_analyzer/figures/correlation.py +166 -0
  123. doe_analyzer/figures/distributions.py +242 -0
  124. doe_analyzer/figures/doe_quality.py +318 -0
  125. doe_analyzer/figures/embedding.py +170 -0
  126. doe_analyzer/figures/figjson.py +82 -0
  127. doe_analyzer/figures/nextruns.py +204 -0
  128. doe_analyzer/figures/optimisation.py +445 -0
  129. doe_analyzer/figures/options.py +176 -0
  130. doe_analyzer/figures/outliers.py +138 -0
  131. doe_analyzer/figures/parallel.py +334 -0
  132. doe_analyzer/figures/pareto.py +347 -0
  133. doe_analyzer/figures/scatter.py +319 -0
  134. doe_analyzer/figures/sensitivity.py +415 -0
  135. doe_analyzer/figures/som.py +188 -0
  136. doe_analyzer/figures/surrogates.py +456 -0
  137. doe_analyzer/figures/theme.py +126 -0
  138. doe_analyzer/figures/whatif.py +1425 -0
  139. doe_analyzer/gui/__init__.py +0 -0
  140. doe_analyzer/gui/app_window.py +1043 -0
  141. doe_analyzer/gui/colorscale_edit.py +202 -0
  142. doe_analyzer/gui/column_panel.py +444 -0
  143. doe_analyzer/gui/data_table.py +223 -0
  144. doe_analyzer/gui/design_dialog.py +262 -0
  145. doe_analyzer/gui/errors.py +174 -0
  146. doe_analyzer/gui/export_dialog.py +251 -0
  147. doe_analyzer/gui/exporter.py +256 -0
  148. doe_analyzer/gui/form_dialog.py +37 -0
  149. doe_analyzer/gui/formula_dialog.py +96 -0
  150. doe_analyzer/gui/help_view.py +129 -0
  151. doe_analyzer/gui/icons.py +62 -0
  152. doe_analyzer/gui/import_dialog.py +93 -0
  153. doe_analyzer/gui/nav.py +87 -0
  154. doe_analyzer/gui/pages/__init__.py +0 -0
  155. doe_analyzer/gui/pages/base.py +662 -0
  156. doe_analyzer/gui/pages/data_page.py +229 -0
  157. doe_analyzer/gui/pages/form.py +173 -0
  158. doe_analyzer/gui/pages/whatif_page.py +262 -0
  159. doe_analyzer/gui/plotview.py +437 -0
  160. doe_analyzer/gui/qss.py +182 -0
  161. doe_analyzer/gui/range_dialog.py +62 -0
  162. doe_analyzer/gui/session.py +25 -0
  163. doe_analyzer/gui/settings.py +87 -0
  164. doe_analyzer/gui/settings_dialog.py +133 -0
  165. doe_analyzer/gui/space_dialog.py +53 -0
  166. doe_analyzer/gui/tasks.py +122 -0
  167. doe_analyzer/gui/theming.py +86 -0
  168. doe_analyzer/gui/web/bridge.js +218 -0
  169. doe_analyzer/gui/web/host.html +16 -0
  170. doe_analyzer/gui/web/titles.js +109 -0
  171. doe_analyzer/gui/widgets.py +376 -0
  172. doe_analyzer/help.py +54 -0
  173. doe_analyzer/logs.py +170 -0
  174. doe_analyzer/main.py +165 -0
  175. doe_analyzer/script.py +251 -0
  176. doe_analyzer/tokens.py +61 -0
  177. doe_analyzer/web/__init__.py +0 -0
  178. doe_analyzer/web/controller.py +1418 -0
  179. doe_analyzer/web/shims/moocore.py +61 -0
  180. doe_analyzer/web/shims/stubs.py +78 -0
  181. doe_analyzer-0.3.0.dist-info/METADATA +207 -0
  182. doe_analyzer-0.3.0.dist-info/RECORD +187 -0
  183. doe_analyzer-0.3.0.dist-info/WHEEL +5 -0
  184. doe_analyzer-0.3.0.dist-info/entry_points.txt +5 -0
  185. doe_analyzer-0.3.0.dist-info/licenses/LICENSE +21 -0
  186. doe_analyzer-0.3.0.dist-info/licenses/doe_analyzer/data/icons/LICENSE +202 -0
  187. doe_analyzer-0.3.0.dist-info/top_level.txt +1 -0
@@ -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)
@@ -0,0 +1,3 @@
1
+ from doe_analyzer.main import main
2
+
3
+ raise SystemExit(main())
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)