discopt-doe 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,282 @@
1
+ """discopt.doe -- Design of Experiments.
2
+
3
+ This package has three complementary entry points, each tailored to
4
+ a different question:
5
+
6
+ 1. **"What is the best operating condition?"**
7
+ :func:`optimize_round` runs one active-learning round: fit a
8
+ surrogate to the experiments completed so far, recommend the
9
+ next batch via an acquisition function (expected improvement,
10
+ UCB, steepest ascent), append the batch to a workbook for
11
+ execution. See the ``active-learning`` tutorial notebook.
12
+
13
+ 2. **"Does this factor matter?"**
14
+ :func:`factorial_2level_design` builds 2-level full factorial
15
+ screening designs; :func:`effects_estimates` gives signed
16
+ main-effect estimates; :func:`anova_report` produces the
17
+ F-table.
18
+
19
+ 3. **"How precisely can I estimate the model parameters?"**
20
+ :func:`optimal_experiment` and :func:`batch_optimal_experiment`
21
+ solve for an exact D/A/E-optimal design using the Fisher
22
+ Information Matrix computed with JAX autodiff;
23
+ :func:`diagnose_identifiability` and
24
+ :func:`estimability_rank` warn when the chosen experiments
25
+ cannot identify the parameters.
26
+
27
+ Quick start (FIM-based parameter-estimation design)
28
+ ---------------------------------------------------
29
+ >>> from discopt.doe import compute_fim, optimal_experiment, DesignCriterion
30
+ >>> fim_result = compute_fim(experiment, param_values, design_values)
31
+ >>> design = optimal_experiment(experiment, param_values, design_bounds)
32
+ >>> print(design.summary())
33
+
34
+ Quick start (active-learning optimization)
35
+ ------------------------------------------
36
+ >>> from discopt.doe import optimize_round, OptimizationCriterion
37
+ >>> result = optimize_round(
38
+ ... workbook="opt.xlsx",
39
+ ... criterion=OptimizationCriterion.MAXIMIZE,
40
+ ... surrogate="gp", # or sklearn estimator, or Surrogate
41
+ ... acquisition="expected_improvement",
42
+ ... batch_size=4,
43
+ ... )
44
+ >>> print(result.next_designs)
45
+
46
+ Identifiability and estimability diagnostics
47
+ --------------------------------------------
48
+ >>> from discopt.doe import diagnose_identifiability, estimability_rank, profile_likelihood
49
+ >>> diag = diagnose_identifiability(experiment, param_values)
50
+ >>> est = estimability_rank(experiment, param_values)
51
+ >>> profile = profile_likelihood(experiment, data, "k")
52
+
53
+ See Also
54
+ --------
55
+ discopt.estimate : Parameter estimation using the same Experiment interface.
56
+ discopt.doe.surrogate : Surrogate model protocol + sklearn adapter.
57
+ discopt.doe.acquisition : Acquisition functions (EI, UCB, steepest ascent).
58
+ """
59
+
60
+ from importlib.metadata import PackageNotFoundError as _PkgNotFound
61
+ from importlib.metadata import version as _dist_version
62
+
63
+ # discopt-doe consumes the public discopt.parametric API and the
64
+ # "discopt.cli" plugin hook, both introduced in discopt 0.6. A stale base
65
+ # install fails here with a clear message instead of an AttributeError later.
66
+ # Two-component compare so 0.6.0.dev0 local builds pass.
67
+ _MIN_DISCOPT = (0, 6)
68
+
69
+
70
+ def _version_prefix(found: str) -> tuple[int, ...]:
71
+ """(major, minor) integer prefix, tolerant of pre-release suffixes.
72
+
73
+ ``"0.6rc1"`` -> ``(0, 6)``, ``"0.7b2"`` -> ``(0, 7)``. The old
74
+ ``int(p) for p in ... if p.isdigit()`` dropped a component like ``"6rc1"``
75
+ entirely and spuriously rejected valid pre-releases.
76
+ """
77
+ import re as _re
78
+
79
+ out: list[int] = []
80
+ for p in found.split(".")[:2]:
81
+ m = _re.match(r"\d+", p)
82
+ if m:
83
+ out.append(int(m.group()))
84
+ return tuple(out)
85
+
86
+
87
+ try:
88
+ _found = _dist_version("discopt")
89
+ _v = _version_prefix(_found)
90
+ if _v < _MIN_DISCOPT:
91
+ raise ImportError(
92
+ f"discopt-doe requires discopt>={'.'.join(map(str, _MIN_DISCOPT))} "
93
+ f"(found {_found}); upgrade with `pip install -U discopt`."
94
+ )
95
+ except _PkgNotFound: # odd dev setups (no dist metadata): don't block
96
+ pass
97
+
98
+ from discopt.doe.acquisition import (
99
+ ACQUISITIONS,
100
+ confidence_bound,
101
+ expected_improvement,
102
+ resolve_acquisition,
103
+ steepest_ascent,
104
+ )
105
+ from discopt.doe.anova import AnovaEffect, AnovaTable, anova_report
106
+ from discopt.doe.design import (
107
+ BatchDesignResult,
108
+ BatchStrategy,
109
+ DesignConstraint,
110
+ DesignCriterion,
111
+ DesignResult,
112
+ batch_optimal_experiment,
113
+ optimal_experiment,
114
+ project_to_simplex,
115
+ sample_simplex,
116
+ sum_constraint,
117
+ )
118
+ from discopt.doe.discrimination import (
119
+ DiscriminationCriterion,
120
+ DiscriminationDesignResult,
121
+ discriminate_compound,
122
+ discriminate_design,
123
+ evaluate_discrimination_criterion,
124
+ )
125
+ from discopt.doe.discrimination_sequential import (
126
+ DiscriminationRound,
127
+ sequential_discrimination,
128
+ )
129
+ from discopt.doe.estimability import (
130
+ EstimabilityResult,
131
+ collinearity_index,
132
+ d_optimal_subset,
133
+ estimability_rank,
134
+ )
135
+ from discopt.doe.exploration import (
136
+ ExplorationResult,
137
+ explore_design_space,
138
+ )
139
+ from discopt.doe.fim import (
140
+ FIMResult,
141
+ IdentifiabilityDiagnostics,
142
+ IdentifiabilityResult,
143
+ check_identifiability,
144
+ compute_fim,
145
+ diagnose_identifiability,
146
+ )
147
+ from discopt.doe.fractional import (
148
+ fractional_factorial_design,
149
+ )
150
+ from discopt.doe.latin import (
151
+ LatinDesign,
152
+ graeco_latin_square,
153
+ hyper_graeco_latin_square,
154
+ latin_square,
155
+ latin_square_design,
156
+ )
157
+ from discopt.doe.model_based import (
158
+ ModelBasedRoundResult,
159
+ ParametricSurrogate,
160
+ model_based_optimize_round,
161
+ )
162
+ from discopt.doe.optimize import (
163
+ OptimizationCriterion,
164
+ OptimizationRoundResult,
165
+ optimize_round,
166
+ )
167
+ from discopt.doe.profile import (
168
+ ProfileLikelihoodResult,
169
+ profile_all,
170
+ profile_likelihood,
171
+ )
172
+ from discopt.doe.screening import (
173
+ FactorialDesign,
174
+ effects_estimates,
175
+ factorial_2level_design,
176
+ )
177
+ from discopt.doe.selection import (
178
+ ModelSelectionResult,
179
+ likelihood_ratio_test,
180
+ model_selection,
181
+ vuong_test,
182
+ )
183
+ from discopt.doe.sequential import (
184
+ DoERound,
185
+ sequential_doe,
186
+ )
187
+ from discopt.doe.surrogate import (
188
+ PRESETS as SURROGATE_PRESETS,
189
+ )
190
+ from discopt.doe.surrogate import (
191
+ Surrogate,
192
+ coerce_surrogate,
193
+ )
194
+ from discopt.doe.templates import (
195
+ TEMPLATE_NAMES,
196
+ build_template,
197
+ linear_template,
198
+ polynomial_1d_template,
199
+ response_surface_template,
200
+ scheffe_linear_template,
201
+ scheffe_quadratic_template,
202
+ scheffe_special_cubic_template,
203
+ simplex_centroid_points,
204
+ simplex_lattice_points,
205
+ )
206
+
207
+ __all__ = [
208
+ "AnovaEffect",
209
+ "AnovaTable",
210
+ "BatchDesignResult",
211
+ "BatchStrategy",
212
+ "DesignConstraint",
213
+ "DesignCriterion",
214
+ "DesignResult",
215
+ "FactorialDesign",
216
+ "LatinDesign",
217
+ "DiscriminationCriterion",
218
+ "DiscriminationDesignResult",
219
+ "DiscriminationRound",
220
+ "DoERound",
221
+ "EstimabilityResult",
222
+ "ExplorationResult",
223
+ "FIMResult",
224
+ "IdentifiabilityDiagnostics",
225
+ "IdentifiabilityResult",
226
+ "ModelBasedRoundResult",
227
+ "ModelSelectionResult",
228
+ "OptimizationCriterion",
229
+ "OptimizationRoundResult",
230
+ "ParametricSurrogate",
231
+ "ProfileLikelihoodResult",
232
+ "SURROGATE_PRESETS",
233
+ "Surrogate",
234
+ "ACQUISITIONS",
235
+ "TEMPLATE_NAMES",
236
+ "anova_report",
237
+ "batch_optimal_experiment",
238
+ "build_template",
239
+ "coerce_surrogate",
240
+ "confidence_bound",
241
+ "expected_improvement",
242
+ "optimize_round",
243
+ "resolve_acquisition",
244
+ "steepest_ascent",
245
+ "check_identifiability",
246
+ "graeco_latin_square",
247
+ "hyper_graeco_latin_square",
248
+ "latin_square",
249
+ "latin_square_design",
250
+ "collinearity_index",
251
+ "compute_fim",
252
+ "d_optimal_subset",
253
+ "diagnose_identifiability",
254
+ "discriminate_compound",
255
+ "effects_estimates",
256
+ "factorial_2level_design",
257
+ "fractional_factorial_design",
258
+ "discriminate_design",
259
+ "estimability_rank",
260
+ "evaluate_discrimination_criterion",
261
+ "explore_design_space",
262
+ "likelihood_ratio_test",
263
+ "linear_template",
264
+ "model_based_optimize_round",
265
+ "model_selection",
266
+ "optimal_experiment",
267
+ "polynomial_1d_template",
268
+ "project_to_simplex",
269
+ "response_surface_template",
270
+ "profile_all",
271
+ "profile_likelihood",
272
+ "sample_simplex",
273
+ "scheffe_linear_template",
274
+ "scheffe_quadratic_template",
275
+ "scheffe_special_cubic_template",
276
+ "sequential_discrimination",
277
+ "sequential_doe",
278
+ "simplex_centroid_points",
279
+ "simplex_lattice_points",
280
+ "sum_constraint",
281
+ "vuong_test",
282
+ ]
@@ -0,0 +1,221 @@
1
+ """Acquisition functions for active-learning optimization.
2
+
3
+ An acquisition function scores candidate design points by how
4
+ *valuable* they are to evaluate next. Higher scores are better; the
5
+ optimizer selects the top-``batch_size`` candidates.
6
+
7
+ Every function in this module has the signature::
8
+
9
+ score(surrogate, X_candidates, *, y_best, direction, **kw) -> ndarray
10
+
11
+ * ``surrogate`` is a fitted object satisfying the
12
+ :class:`~discopt.doe.surrogate.Surrogate` protocol.
13
+ * ``X_candidates`` is a 2D array of shape ``(n, d)``.
14
+ * ``y_best`` is the best observed response so far (incumbent).
15
+ * ``direction`` is ``+1`` for *maximize* or ``-1`` for *minimize*.
16
+
17
+ Implementations
18
+ ---------------
19
+
20
+ * :func:`expected_improvement` -- the standard EI used in Bayesian
21
+ optimization. Balances exploitation (high predicted mean in the
22
+ desired direction) and exploration (high uncertainty).
23
+ * :func:`upper_confidence_bound` / :func:`lower_confidence_bound` --
24
+ UCB / LCB. A direction-aware wrapper :func:`confidence_bound` picks
25
+ the right sign automatically.
26
+ * :func:`steepest_ascent` -- Box-Wilson style: score points by the
27
+ predicted improvement only (ignores uncertainty). Useful with a
28
+ response-surface surrogate when you want classical RSM behaviour.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import math
34
+ from typing import Callable, Literal
35
+
36
+ import numpy as np
37
+
38
+ from discopt.doe.surrogate import Surrogate
39
+
40
+ Direction = Literal[1, -1]
41
+
42
+
43
+ def _erf(x: np.ndarray) -> np.ndarray:
44
+ return np.asarray(np.vectorize(math.erf)(x), dtype=float)
45
+
46
+
47
+ def _norm_cdf(x: np.ndarray) -> np.ndarray:
48
+ return 0.5 * (1.0 + _erf(x / math.sqrt(2.0)))
49
+
50
+
51
+ def _norm_pdf(x: np.ndarray) -> np.ndarray:
52
+ return np.asarray(np.exp(-0.5 * x * x) / math.sqrt(2.0 * math.pi), dtype=float)
53
+
54
+
55
+ def expected_improvement(
56
+ surrogate: Surrogate,
57
+ X_candidates: np.ndarray,
58
+ *,
59
+ y_best: float,
60
+ direction: Direction,
61
+ xi: float = 0.0,
62
+ ) -> np.ndarray:
63
+ """Expected improvement over the incumbent ``y_best``.
64
+
65
+ For maximization (``direction = +1``)::
66
+
67
+ EI(x) = (μ - y_best - xi) Φ(z) + σ φ(z)
68
+ z = (μ - y_best - xi) / σ
69
+
70
+ For minimization (``direction = -1``), the sign flips so EI is
71
+ always non-negative for points that improve on the incumbent.
72
+
73
+ ``xi`` is an exploration knob -- a small positive value
74
+ (e.g. ``0.01``) demands a slightly stronger improvement and
75
+ encourages exploration.
76
+ """
77
+ mu, sigma = surrogate.predict(np.asarray(X_candidates, dtype=float))
78
+ mu = np.asarray(mu, dtype=float).ravel()
79
+ sigma = np.asarray(sigma, dtype=float).ravel()
80
+ dir_sign = int(direction)
81
+ if dir_sign not in (1, -1):
82
+ raise ValueError(f"direction must be +1 or -1, got {dir_sign}")
83
+
84
+ improvement = dir_sign * (mu - y_best) - xi
85
+ safe_sigma = np.where(sigma > 0.0, sigma, 1.0)
86
+ z = improvement / safe_sigma
87
+ ei = improvement * _norm_cdf(z) + safe_sigma * _norm_pdf(z)
88
+ ei = np.where(sigma > 0.0, ei, np.maximum(improvement, 0.0))
89
+ return ei
90
+
91
+
92
+ def upper_confidence_bound(
93
+ surrogate: Surrogate,
94
+ X_candidates: np.ndarray,
95
+ *,
96
+ kappa: float = 2.0,
97
+ ) -> np.ndarray:
98
+ """``μ + κ σ`` -- maximize when ``direction = +1``."""
99
+ mu, sigma = surrogate.predict(np.asarray(X_candidates, dtype=float))
100
+ return np.asarray(mu).ravel() + float(kappa) * np.asarray(sigma).ravel()
101
+
102
+
103
+ def lower_confidence_bound(
104
+ surrogate: Surrogate,
105
+ X_candidates: np.ndarray,
106
+ *,
107
+ kappa: float = 2.0,
108
+ ) -> np.ndarray:
109
+ """``-(μ - κ σ)`` -- larger is *better* under minimization."""
110
+ mu, sigma = surrogate.predict(np.asarray(X_candidates, dtype=float))
111
+ return -(np.asarray(mu).ravel() - float(kappa) * np.asarray(sigma).ravel())
112
+
113
+
114
+ def confidence_bound(
115
+ surrogate: Surrogate,
116
+ X_candidates: np.ndarray,
117
+ *,
118
+ direction: Direction,
119
+ kappa: float = 2.0,
120
+ ) -> np.ndarray:
121
+ """Direction-aware UCB/LCB. Higher score = better candidate."""
122
+ if direction == 1:
123
+ return upper_confidence_bound(surrogate, X_candidates, kappa=kappa)
124
+ if direction == -1:
125
+ return lower_confidence_bound(surrogate, X_candidates, kappa=kappa)
126
+ raise ValueError(f"direction must be +1 or -1, got {direction}")
127
+
128
+
129
+ def steepest_ascent(
130
+ surrogate: Surrogate,
131
+ X_candidates: np.ndarray,
132
+ *,
133
+ direction: Direction,
134
+ y_best: float | None = None,
135
+ ) -> np.ndarray:
136
+ """Predicted improvement only -- ignores uncertainty.
137
+
138
+ Score is ``direction * μ(x)`` (or, if ``y_best`` is supplied,
139
+ ``direction * (μ(x) - y_best)``, which only shifts the score and
140
+ does not change the ranking). Use this with a response-surface
141
+ surrogate to reproduce classical Box-Wilson behaviour.
142
+ """
143
+ mu, _sigma = surrogate.predict(np.asarray(X_candidates, dtype=float))
144
+ mu = np.asarray(mu, dtype=float).ravel()
145
+ if y_best is None:
146
+ return int(direction) * mu
147
+ return int(direction) * (mu - float(y_best))
148
+
149
+
150
+ # Note: "ucb" and "lcb" both resolve to the *direction-aware* confidence_bound
151
+ # wrapper, which picks upper (maximize) or lower (minimize) from the round's
152
+ # direction. So under maximization "lcb" still computes the UCB (and vice
153
+ # versa) -- the names are aliases for "confidence bound", not a hard choice of
154
+ # upper vs lower. Use the raw upper_confidence_bound / lower_confidence_bound
155
+ # callables directly if you need a fixed side.
156
+ ACQUISITIONS: dict[str, Callable[..., np.ndarray]] = {
157
+ "expected_improvement": expected_improvement,
158
+ "ei": expected_improvement,
159
+ "ucb": confidence_bound,
160
+ "lcb": confidence_bound,
161
+ "confidence_bound": confidence_bound,
162
+ "steepest_ascent": steepest_ascent,
163
+ }
164
+
165
+
166
+ def resolve_acquisition(name_or_fn):
167
+ """Look up an acquisition function by string or pass through a callable."""
168
+ if callable(name_or_fn):
169
+ return name_or_fn
170
+ try:
171
+ return ACQUISITIONS[name_or_fn]
172
+ except KeyError as e:
173
+ raise ValueError(
174
+ f"unknown acquisition {name_or_fn!r}; available: {sorted(set(ACQUISITIONS))}"
175
+ ) from e
176
+
177
+
178
+ def call_acquisition(acq_fn, surrogate, X_candidates, *, direction, y_best, acq_kwargs=None):
179
+ """Invoke an acquisition, passing only the arguments it accepts.
180
+
181
+ Replaces a ``try/except TypeError`` dispatch that both masked genuine
182
+ errors raised *inside* an acquisition and silently discarded the caller's
183
+ tuning kwargs. ``direction``/``y_best`` are injected only when the callable
184
+ declares them (or has ``**kwargs``), and user ``acq_kwargs`` that the
185
+ callable cannot consume raise a clear error instead of being swallowed.
186
+ """
187
+ import inspect
188
+
189
+ acq_kwargs = dict(acq_kwargs or {})
190
+ params = inspect.signature(acq_fn).parameters
191
+ has_var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
192
+ named = {
193
+ n
194
+ for n, p in params.items()
195
+ if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
196
+ }
197
+ if not has_var_kw:
198
+ bad = [k for k in acq_kwargs if k not in named]
199
+ if bad:
200
+ tunable = sorted(named - {"surrogate", "X_candidates", "direction", "y_best"})
201
+ raise TypeError(
202
+ f"acquisition {getattr(acq_fn, '__name__', acq_fn)!r} got unexpected "
203
+ f"keyword(s) {bad}; it accepts {tunable}."
204
+ )
205
+ call_kwargs = dict(acq_kwargs)
206
+ if "direction" in named or has_var_kw:
207
+ call_kwargs["direction"] = direction
208
+ if "y_best" in named or has_var_kw:
209
+ call_kwargs["y_best"] = y_best
210
+ return acq_fn(surrogate, X_candidates, **call_kwargs)
211
+
212
+
213
+ __all__ = [
214
+ "ACQUISITIONS",
215
+ "confidence_bound",
216
+ "expected_improvement",
217
+ "lower_confidence_bound",
218
+ "resolve_acquisition",
219
+ "steepest_ascent",
220
+ "upper_confidence_bound",
221
+ ]