perfusio 0.1.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.
- perfusio/__init__.py +38 -0
- perfusio/_typing.py +152 -0
- perfusio/bed/__init__.py +37 -0
- perfusio/bed/acquisitions.py +219 -0
- perfusio/bed/objectives.py +238 -0
- perfusio/bed/pareto.py +119 -0
- perfusio/bed/policies.py +178 -0
- perfusio/bed/search.py +101 -0
- perfusio/chemistry/__init__.py +20 -0
- perfusio/chemistry/balances.py +430 -0
- perfusio/chemistry/species.py +312 -0
- perfusio/chemistry/volumes.py +178 -0
- perfusio/cli.py +407 -0
- perfusio/config.py +453 -0
- perfusio/connectors/__init__.py +27 -0
- perfusio/connectors/ambr250_emulator.py +117 -0
- perfusio/connectors/base.py +59 -0
- perfusio/connectors/filesystem.py +157 -0
- perfusio/connectors/opcua_client.py +203 -0
- perfusio/connectors/sql_store.py +296 -0
- perfusio/embedding/__init__.py +27 -0
- perfusio/embedding/clones.py +168 -0
- perfusio/embedding/transfer.py +199 -0
- perfusio/gp/__init__.py +25 -0
- perfusio/gp/ensemble.py +202 -0
- perfusio/gp/exact_gp.py +137 -0
- perfusio/gp/kernels.py +131 -0
- perfusio/gp/means.py +167 -0
- perfusio/gp/stepwise.py +242 -0
- perfusio/hybrid/__init__.py +26 -0
- perfusio/hybrid/forecast.py +85 -0
- perfusio/hybrid/model.py +183 -0
- perfusio/hybrid/train.py +160 -0
- perfusio/mechanistic/__init__.py +24 -0
- perfusio/mechanistic/integrators.py +140 -0
- perfusio/mechanistic/kinetics.py +460 -0
- perfusio/mechanistic/models.py +164 -0
- perfusio/metrics/__init__.py +26 -0
- perfusio/metrics/coverage.py +122 -0
- perfusio/metrics/multiobjective.py +112 -0
- perfusio/metrics/rrmse.py +100 -0
- perfusio/simulator/__init__.py +25 -0
- perfusio/simulator/cho_perfusion.py +256 -0
- perfusio/simulator/doe.py +179 -0
- perfusio/simulator/noise.py +107 -0
- perfusio/states.py +445 -0
- perfusio/twin/__init__.py +24 -0
- perfusio/twin/audit.py +134 -0
- perfusio/twin/digital_twin.py +302 -0
- perfusio/twin/notifications.py +205 -0
- perfusio/twin/scheduler.py +94 -0
- perfusio/viz/__init__.py +14 -0
- perfusio/viz/dashboard/__init__.py +1 -0
- perfusio/viz/dashboard/app.py +191 -0
- perfusio/viz/interactive.py +278 -0
- perfusio/viz/pareto_explorer.py +8 -0
- perfusio/viz/static.py +337 -0
- perfusio/viz/theme.py +101 -0
- perfusio-0.1.0.dist-info/METADATA +221 -0
- perfusio-0.1.0.dist-info/RECORD +63 -0
- perfusio-0.1.0.dist-info/WHEEL +4 -0
- perfusio-0.1.0.dist-info/entry_points.txt +2 -0
- perfusio-0.1.0.dist-info/licenses/LICENSE +170 -0
perfusio/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""perfusio — open reference implementation of self-driving perfusion bioprocess development.
|
|
2
|
+
|
|
3
|
+
``perfusio`` implements the methodology of Gadiyar et al. (2026) and Hutter et al. (2021),
|
|
4
|
+
providing step-wise Gaussian-process hybrid models, entity-embedding transfer learning,
|
|
5
|
+
Bayesian Experimental Design, and an online-retraining digital twin for CHO perfusion
|
|
6
|
+
bioreactors. It is intended as an open, citable complement to commercial platforms.
|
|
7
|
+
|
|
8
|
+
References
|
|
9
|
+
----------
|
|
10
|
+
.. [Gadiyar2026] Gadiyar, C. J., Müller, C., Vuillemin, T., Bielser, J.-M., Souquet, J.,
|
|
11
|
+
Fagnani, A., Sokolov, M., von Stosch, M., Feidl, F., Butté, A., &
|
|
12
|
+
Cruz Bournazou, M. N. (2026). Self-Driving Development of Perfusion Processes
|
|
13
|
+
for Monoclonal Antibody Production. *Biotechnology and Bioengineering*, 123(2),
|
|
14
|
+
391–405. https://doi.org/10.1002/bit.70093
|
|
15
|
+
|
|
16
|
+
.. [Hutter2021] Hutter, S., von Stosch, M., Cruz Bournazou, M. N., & Butté, A. (2021).
|
|
17
|
+
Knowledge transfer across cell lines using hybrid Gaussian process models with
|
|
18
|
+
entity embedding vectors. *Biotechnology and Bioengineering*, 118(12), 4710–4725.
|
|
19
|
+
https://doi.org/10.1002/bit.27907
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__author__ = "perfusio Contributors"
|
|
26
|
+
__license__ = "Apache-2.0"
|
|
27
|
+
|
|
28
|
+
from perfusio.config import DesignSpace, RunConfig
|
|
29
|
+
from perfusio.states import State, StateBatch, Trajectory
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DesignSpace",
|
|
33
|
+
"RunConfig",
|
|
34
|
+
"State",
|
|
35
|
+
"StateBatch",
|
|
36
|
+
"Trajectory",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
perfusio/_typing.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Custom types, TypeAliases, and Protocols for ``perfusio``.
|
|
2
|
+
|
|
3
|
+
This module is the single place where ``Any`` may appear (in Protocol stubs
|
|
4
|
+
and adapter contracts). All other modules must import from here rather than
|
|
5
|
+
using ``typing.Any`` directly.
|
|
6
|
+
|
|
7
|
+
Notes
|
|
8
|
+
-----
|
|
9
|
+
Only import from this module using ``TYPE_CHECKING`` guards in runtime code
|
|
10
|
+
to avoid circular imports and keep start-up cost low.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable, Mapping
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Protocol, TypeAlias, runtime_checkable
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import torch
|
|
21
|
+
from torch import Tensor
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Scalar types
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
#: A Python float, int, or zero-dimensional torch tensor.
|
|
28
|
+
Numeric: TypeAlias = float | int | Tensor
|
|
29
|
+
|
|
30
|
+
#: A 1-D array of float32/float64 (numpy or torch).
|
|
31
|
+
Vector: TypeAlias = Tensor | np.ndarray[Any, np.dtype[np.floating[Any]]]
|
|
32
|
+
|
|
33
|
+
#: A 2-D array of float64.
|
|
34
|
+
Matrix: TypeAlias = Tensor | np.ndarray[Any, np.dtype[np.floating[Any]]]
|
|
35
|
+
|
|
36
|
+
#: A path-like object or string.
|
|
37
|
+
PathLike: TypeAlias = Path | str
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Shape annotations (documentation only — not enforced at runtime)
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
#: Batch dimension over reactors.
|
|
43
|
+
_B = int
|
|
44
|
+
#: Number of species / tasks.
|
|
45
|
+
_K = int
|
|
46
|
+
#: Time steps.
|
|
47
|
+
_T = int
|
|
48
|
+
#: State dimension.
|
|
49
|
+
_D = int
|
|
50
|
+
#: GP input dimension.
|
|
51
|
+
_Din = int
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Acquisition function type
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
#: A callable that maps (candidate_points: Tensor) -> Tensor (acquisition values).
|
|
58
|
+
AcquisitionFn: TypeAlias = Callable[[Tensor], Tensor]
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Protocols
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@runtime_checkable
|
|
66
|
+
class BioreactorConnector(Protocol):
|
|
67
|
+
"""Protocol that all bioreactor data connectors must satisfy.
|
|
68
|
+
|
|
69
|
+
Both the real OPC UA client and the ambr®250 emulator implement this
|
|
70
|
+
protocol so that the :class:`~perfusio.twin.digital_twin.DigitalTwin`
|
|
71
|
+
can be tested offline.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
async def read_sample(self, day: int) -> Mapping[str, float]:
|
|
75
|
+
"""Return the latest at-line sample for *day*.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
day:
|
|
80
|
+
Culture day index (1-based).
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
Mapping[str, float]
|
|
85
|
+
Species name → measured value, e.g.
|
|
86
|
+
``{"VCD": 12.3, "Via": 97.1, "Glc": 1.8}``.
|
|
87
|
+
|
|
88
|
+
Raises
|
|
89
|
+
------
|
|
90
|
+
ConnectionError
|
|
91
|
+
If the connector is offline and cannot recover.
|
|
92
|
+
"""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
async def write_setpoints(self, setpoints: Mapping[str, float]) -> None:
|
|
96
|
+
"""Write control setpoints to the reactor controller.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
setpoints:
|
|
101
|
+
Control variable name → desired value.
|
|
102
|
+
|
|
103
|
+
Raises
|
|
104
|
+
------
|
|
105
|
+
PermissionError
|
|
106
|
+
If the connector is in read-only mode
|
|
107
|
+
(``--allow-write`` flag not provided).
|
|
108
|
+
ConnectionError
|
|
109
|
+
If the write fails.
|
|
110
|
+
"""
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
async def is_alive(self) -> bool:
|
|
114
|
+
"""Return ``True`` if the connection is healthy."""
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@runtime_checkable
|
|
119
|
+
class Fittable(Protocol):
|
|
120
|
+
"""Any object with a ``fit`` method."""
|
|
121
|
+
|
|
122
|
+
def fit(self, *args: Any, **kwargs: Any) -> None:
|
|
123
|
+
"""Fit the model in-place."""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@runtime_checkable
|
|
128
|
+
class Predictable(Protocol):
|
|
129
|
+
"""Any object with a ``predict`` method returning a Tensor."""
|
|
130
|
+
|
|
131
|
+
def predict(self, x: Tensor) -> Tensor:
|
|
132
|
+
"""Return point predictions at *x*."""
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Callback / hook types
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
#: A hook called after every digital-twin step with the step result.
|
|
141
|
+
StepCallback: TypeAlias = Callable[..., None]
|
|
142
|
+
|
|
143
|
+
#: Factory producing a fresh model instance (used by ensemble).
|
|
144
|
+
ModelFactory: TypeAlias = Callable[[], Any]
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Device / dtype helpers
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
#: Default torch dtype — double precision everywhere.
|
|
151
|
+
DEFAULT_DTYPE: torch.dtype = torch.float64
|
|
152
|
+
DEFAULT_DEVICE: torch.device = torch.device("cpu")
|
perfusio/bed/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Bayesian Experimental Design (BED) sub-package for ``perfusio``.
|
|
2
|
+
|
|
3
|
+
Implements the full BED decision loop described in Gadiyar et al. (2026) §3.2,
|
|
4
|
+
including:
|
|
5
|
+
|
|
6
|
+
- Objective function values (OFV) — target-tracking and multi-objective.
|
|
7
|
+
- All 11 BoTorch acquisition functions (PI, EI, LogEI, UCB, qEI, qLogEI,
|
|
8
|
+
qUCB, qEHVI, qNEHVI, qNParEGO) plus constrained variants.
|
|
9
|
+
- Acquisition optimisation over the design space.
|
|
10
|
+
- Pareto front computation and hypervolume indicator.
|
|
11
|
+
- High-level :class:`BEDPolicy` daily decision loop.
|
|
12
|
+
|
|
13
|
+
Public API
|
|
14
|
+
----------
|
|
15
|
+
- :class:`~perfusio.bed.objectives.TargetTrackingOFV`
|
|
16
|
+
- :class:`~perfusio.bed.objectives.MultiObjectiveOFV`
|
|
17
|
+
- :func:`~perfusio.bed.acquisitions.build_acquisition`
|
|
18
|
+
- :func:`~perfusio.bed.search.optimise_acquisition`
|
|
19
|
+
- :func:`~perfusio.bed.pareto.compute_pareto_front`
|
|
20
|
+
- :class:`~perfusio.bed.policies.BEDPolicy`
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from perfusio.bed.acquisitions import build_acquisition
|
|
24
|
+
from perfusio.bed.objectives import MultiObjectiveOFV, TargetTrackingOFV
|
|
25
|
+
from perfusio.bed.pareto import compute_pareto_front, hypervolume
|
|
26
|
+
from perfusio.bed.policies import BEDPolicy
|
|
27
|
+
from perfusio.bed.search import optimise_acquisition
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"BEDPolicy",
|
|
31
|
+
"MultiObjectiveOFV",
|
|
32
|
+
"TargetTrackingOFV",
|
|
33
|
+
"build_acquisition",
|
|
34
|
+
"compute_pareto_front",
|
|
35
|
+
"hypervolume",
|
|
36
|
+
"optimise_acquisition",
|
|
37
|
+
]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""BoTorch acquisition function factory for Bayesian Experimental Design.
|
|
2
|
+
|
|
3
|
+
Provides :func:`build_acquisition`, a single entry-point that constructs any of
|
|
4
|
+
the 11 acquisition functions supported by ``perfusio``:
|
|
5
|
+
|
|
6
|
+
Single-objective (analytic):
|
|
7
|
+
``PI``, ``EI``, ``LogEI``, ``UCB``
|
|
8
|
+
|
|
9
|
+
Single-objective (Monte Carlo):
|
|
10
|
+
``qEI``, ``qLogEI``, ``qUCB``
|
|
11
|
+
|
|
12
|
+
Multi-objective (Monte Carlo):
|
|
13
|
+
``qEHVI``, ``qNEHVI``, ``qNParEGO``
|
|
14
|
+
|
|
15
|
+
Constrained variants are automatically applied when ``constraints`` are passed.
|
|
16
|
+
|
|
17
|
+
References
|
|
18
|
+
----------
|
|
19
|
+
.. [Balandat2020] Balandat, M., et al. (2020). BoTorch: A Framework for
|
|
20
|
+
Efficient Monte-Carlo Bayesian Optimization. NeurIPS.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import botorch.acquisition as ba
|
|
29
|
+
import botorch.acquisition.multi_objective as bamo
|
|
30
|
+
import torch
|
|
31
|
+
from botorch.acquisition.analytic import (
|
|
32
|
+
ExpectedImprovement,
|
|
33
|
+
LogExpectedImprovement,
|
|
34
|
+
ProbabilityOfImprovement,
|
|
35
|
+
UpperConfidenceBound,
|
|
36
|
+
)
|
|
37
|
+
from botorch.acquisition.monte_carlo import (
|
|
38
|
+
qExpectedImprovement,
|
|
39
|
+
qUpperConfidenceBound,
|
|
40
|
+
)
|
|
41
|
+
from botorch.acquisition.multi_objective.parego import qLogNParEGO
|
|
42
|
+
from botorch.models.model import Model
|
|
43
|
+
from torch import Tensor
|
|
44
|
+
|
|
45
|
+
# Supported acquisition names
|
|
46
|
+
_SINGLE_OBJ_ANALYTIC = {"PI", "EI", "LogEI", "UCB"}
|
|
47
|
+
_SINGLE_OBJ_MC = {"qEI", "qLogEI", "qUCB"}
|
|
48
|
+
_MULTI_OBJ_MC = {"qEHVI", "qNEHVI", "qNParEGO"}
|
|
49
|
+
ALL_ACQUISITIONS = _SINGLE_OBJ_ANALYTIC | _SINGLE_OBJ_MC | _MULTI_OBJ_MC
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_acquisition(
|
|
53
|
+
name: str,
|
|
54
|
+
model: Model,
|
|
55
|
+
best_f: float | None = None,
|
|
56
|
+
beta: float = 0.2,
|
|
57
|
+
ref_point: list[float] | None = None,
|
|
58
|
+
partitioning: Any = None,
|
|
59
|
+
sampler: Any = None,
|
|
60
|
+
constraints: list[Callable[[Tensor], Tensor]] | None = None,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> ba.AcquisitionFunction:
|
|
63
|
+
"""Construct a BoTorch acquisition function by name.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
name:
|
|
68
|
+
One of: ``PI``, ``EI``, ``LogEI``, ``UCB``,
|
|
69
|
+
``qEI``, ``qLogEI``, ``qUCB``, ``qEHVI``, ``qNEHVI``, ``qNParEGO``.
|
|
70
|
+
model:
|
|
71
|
+
Fitted BoTorch / GPyTorch surrogate model.
|
|
72
|
+
best_f:
|
|
73
|
+
Incumbent best observed value. Required for ``PI``, ``EI``,
|
|
74
|
+
``LogEI``, ``qEI``, ``qLogEI``.
|
|
75
|
+
beta:
|
|
76
|
+
UCB exploration parameter. Required for ``UCB``, ``qUCB``.
|
|
77
|
+
ref_point:
|
|
78
|
+
Reference point for hypervolume-based acquisitions (``qEHVI``,
|
|
79
|
+
``qNEHVI``). Should be strictly dominated by all feasible points.
|
|
80
|
+
partitioning:
|
|
81
|
+
Pre-computed box decomposition (``NondominatedPartitioning``).
|
|
82
|
+
Required for ``qEHVI``.
|
|
83
|
+
sampler:
|
|
84
|
+
MC sampler (``IIDNormalSampler`` or ``SobolQMCNormalSampler``).
|
|
85
|
+
Defaults to ``SobolQMCNormalSampler(512)`` for MC acquisitions.
|
|
86
|
+
constraints:
|
|
87
|
+
Optional list of constraint callables ``c(X) <= 0`` to enforce.
|
|
88
|
+
Applied as soft-constraint penalties where available.
|
|
89
|
+
**kwargs:
|
|
90
|
+
Additional keyword arguments forwarded to the acquisition constructor.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
botorch.acquisition.AcquisitionFunction
|
|
95
|
+
|
|
96
|
+
Raises
|
|
97
|
+
------
|
|
98
|
+
ValueError
|
|
99
|
+
If *name* is not a recognised acquisition type.
|
|
100
|
+
"""
|
|
101
|
+
if name not in ALL_ACQUISITIONS:
|
|
102
|
+
msg = f"Unknown acquisition '{name}'. Choose one of: {sorted(ALL_ACQUISITIONS)}"
|
|
103
|
+
raise ValueError(msg)
|
|
104
|
+
|
|
105
|
+
if sampler is None and name in (_SINGLE_OBJ_MC | _MULTI_OBJ_MC):
|
|
106
|
+
from botorch.sampling.normal import SobolQMCNormalSampler
|
|
107
|
+
|
|
108
|
+
sampler = SobolQMCNormalSampler(sample_shape=torch.Size([512]))
|
|
109
|
+
|
|
110
|
+
# ── Analytic single-objective ──────────────────────────────────────────
|
|
111
|
+
if name == "PI":
|
|
112
|
+
_require(best_f, "PI", "best_f")
|
|
113
|
+
assert best_f is not None
|
|
114
|
+
return ProbabilityOfImprovement(model=model, best_f=best_f, **kwargs)
|
|
115
|
+
|
|
116
|
+
if name == "EI":
|
|
117
|
+
_require(best_f, "EI", "best_f")
|
|
118
|
+
assert best_f is not None
|
|
119
|
+
return ExpectedImprovement(model=model, best_f=best_f, **kwargs)
|
|
120
|
+
|
|
121
|
+
if name == "LogEI":
|
|
122
|
+
_require(best_f, "LogEI", "best_f")
|
|
123
|
+
assert best_f is not None
|
|
124
|
+
return LogExpectedImprovement(model=model, best_f=best_f, **kwargs)
|
|
125
|
+
|
|
126
|
+
if name == "UCB":
|
|
127
|
+
return UpperConfidenceBound(model=model, beta=beta, **kwargs)
|
|
128
|
+
|
|
129
|
+
# ── MC single-objective ────────────────────────────────────────────────
|
|
130
|
+
if name == "qEI":
|
|
131
|
+
_require(best_f, "qEI", "best_f")
|
|
132
|
+
assert best_f is not None
|
|
133
|
+
if constraints:
|
|
134
|
+
return qExpectedImprovement(
|
|
135
|
+
model=model, best_f=best_f, sampler=sampler, constraints=constraints, **kwargs
|
|
136
|
+
)
|
|
137
|
+
return qExpectedImprovement(model=model, best_f=best_f, sampler=sampler, **kwargs)
|
|
138
|
+
|
|
139
|
+
if name == "qLogEI":
|
|
140
|
+
_require(best_f, "qLogEI", "best_f")
|
|
141
|
+
assert best_f is not None
|
|
142
|
+
from botorch.acquisition.logei import qLogExpectedImprovement
|
|
143
|
+
|
|
144
|
+
return qLogExpectedImprovement(model=model, best_f=best_f, sampler=sampler, **kwargs)
|
|
145
|
+
|
|
146
|
+
if name == "qUCB":
|
|
147
|
+
return qUpperConfidenceBound(model=model, beta=beta, sampler=sampler, **kwargs)
|
|
148
|
+
|
|
149
|
+
# ── Multi-objective MC ─────────────────────────────────────────────────
|
|
150
|
+
if name == "qEHVI":
|
|
151
|
+
_require(ref_point, "qEHVI", "ref_point")
|
|
152
|
+
_require(partitioning, "qEHVI", "partitioning")
|
|
153
|
+
assert ref_point is not None
|
|
154
|
+
return bamo.qExpectedHypervolumeImprovement(
|
|
155
|
+
model=model,
|
|
156
|
+
ref_point=ref_point,
|
|
157
|
+
partitioning=partitioning,
|
|
158
|
+
sampler=sampler,
|
|
159
|
+
constraints=constraints,
|
|
160
|
+
**kwargs,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if name == "qNEHVI":
|
|
164
|
+
_require(ref_point, "qNEHVI", "ref_point")
|
|
165
|
+
assert ref_point is not None
|
|
166
|
+
# X_baseline defaults to the model's training inputs when not supplied
|
|
167
|
+
_x_bl = kwargs.pop(
|
|
168
|
+
"X_baseline",
|
|
169
|
+
model.train_inputs[0] # pyright: ignore[reportIndexIssue]
|
|
170
|
+
if hasattr(model, "train_inputs") and model.train_inputs
|
|
171
|
+
else None, # type: ignore[attr-defined]
|
|
172
|
+
)
|
|
173
|
+
if _x_bl is None:
|
|
174
|
+
msg = "Acquisition 'qNEHVI' requires 'X_baseline' or a fitted model with train_inputs."
|
|
175
|
+
raise ValueError(msg)
|
|
176
|
+
# Strip leading batch dim produced by batched multi-output GPs
|
|
177
|
+
if isinstance(_x_bl, Tensor) and _x_bl.dim() == 3:
|
|
178
|
+
_x_bl = _x_bl[0]
|
|
179
|
+
return bamo.qNoisyExpectedHypervolumeImprovement(
|
|
180
|
+
model=model,
|
|
181
|
+
ref_point=ref_point,
|
|
182
|
+
X_baseline=_x_bl,
|
|
183
|
+
sampler=sampler,
|
|
184
|
+
constraints=constraints,
|
|
185
|
+
**kwargs,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if name == "qNParEGO":
|
|
189
|
+
_x_bl = kwargs.pop(
|
|
190
|
+
"X_baseline",
|
|
191
|
+
model.train_inputs[0] # pyright: ignore[reportIndexIssue]
|
|
192
|
+
if hasattr(model, "train_inputs") and model.train_inputs
|
|
193
|
+
else None, # type: ignore[attr-defined]
|
|
194
|
+
)
|
|
195
|
+
if _x_bl is None:
|
|
196
|
+
msg = (
|
|
197
|
+
"Acquisition 'qNParEGO' requires 'X_baseline' or a fitted model with train_inputs."
|
|
198
|
+
)
|
|
199
|
+
raise ValueError(msg)
|
|
200
|
+
# Strip leading batch dim produced by batched multi-output GPs
|
|
201
|
+
if isinstance(_x_bl, Tensor) and _x_bl.dim() == 3:
|
|
202
|
+
_x_bl = _x_bl[0]
|
|
203
|
+
return qLogNParEGO(
|
|
204
|
+
model=model,
|
|
205
|
+
X_baseline=_x_bl,
|
|
206
|
+
sampler=sampler,
|
|
207
|
+
constraints=constraints,
|
|
208
|
+
**kwargs,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Should never reach here — kept for static analysis
|
|
212
|
+
msg = f"Unhandled acquisition name: {name}"
|
|
213
|
+
raise AssertionError(msg)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _require(value: object | None, acq_name: str, param_name: str) -> None:
|
|
217
|
+
if value is None:
|
|
218
|
+
msg = f"Acquisition '{acq_name}' requires '{param_name}' to be provided."
|
|
219
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Objective function values (OFVs) for Bayesian Experimental Design.
|
|
2
|
+
|
|
3
|
+
Implements Gadiyar et al. (2026) Eq. 7:
|
|
4
|
+
|
|
5
|
+
.. math::
|
|
6
|
+
\\text{OFV}(\\mathbf{u}) =
|
|
7
|
+
\\sum_{j=1}^{3}
|
|
8
|
+
\\left( \\hat{y}_{t+j}(\\mathbf{u}) - y_{\\text{target}} \\right)^2
|
|
9
|
+
|
|
10
|
+
where :math:`\\hat{y}_{t+j}` is the *j*-step-ahead hybrid model prediction
|
|
11
|
+
(50th-percentile / mean) for the chosen objective species (e.g. VCD, Titer,
|
|
12
|
+
viability).
|
|
13
|
+
|
|
14
|
+
The OFV is used to evaluate candidate control setpoints :math:`\\mathbf{u}`
|
|
15
|
+
proposed by the acquisition function.
|
|
16
|
+
|
|
17
|
+
References
|
|
18
|
+
----------
|
|
19
|
+
.. [Gadiyar2026] Gadiyar et al. (2026), §3.2, Eq. (7).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
import torch
|
|
28
|
+
from torch import Tensor
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from perfusio.hybrid.model import HybridStateSpaceModel
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TargetSpec:
|
|
36
|
+
"""Specification for a single tracked species target."""
|
|
37
|
+
|
|
38
|
+
species_index: int # column index in the state tensor
|
|
39
|
+
target: float # target value
|
|
40
|
+
weight: float = 1.0 # relative weight in the OFV sum
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TargetTrackingOFV:
|
|
44
|
+
"""3-step-ahead squared-error OFV from Gadiyar et al. (2026) Eq. 7.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
hybrid:
|
|
49
|
+
Fitted hybrid model for forecasting.
|
|
50
|
+
targets:
|
|
51
|
+
List of :class:`TargetSpec` objects — one per tracked species.
|
|
52
|
+
horizon:
|
|
53
|
+
Prediction horizon in days. Default 3 (Gadiyar §3.2).
|
|
54
|
+
n_samples:
|
|
55
|
+
MC rollout samples. Default 100 (fast for BED inner loop).
|
|
56
|
+
seed:
|
|
57
|
+
Fixed random seed for reproducible OFV evaluations.
|
|
58
|
+
|
|
59
|
+
Examples
|
|
60
|
+
--------
|
|
61
|
+
>>> from perfusio.bed.objectives import TargetTrackingOFV, TargetSpec
|
|
62
|
+
>>> # assume `hybrid` is a fitted HybridStateSpaceModel
|
|
63
|
+
>>> ofv = TargetTrackingOFV(
|
|
64
|
+
... hybrid=hybrid,
|
|
65
|
+
... targets=[TargetSpec(0, target=30.0, weight=1.0)], # VCD → 30e6 cells/mL
|
|
66
|
+
... )
|
|
67
|
+
>>> # ofv.evaluate(c0, u_candidate) # returns scalar Tensor
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
targets: list[TargetSpec],
|
|
73
|
+
hybrid: HybridStateSpaceModel | None = None,
|
|
74
|
+
horizon: int = 3,
|
|
75
|
+
n_samples: int = 100,
|
|
76
|
+
seed: int = 42,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.hybrid = hybrid
|
|
79
|
+
self.targets = targets
|
|
80
|
+
self.horizon = horizon
|
|
81
|
+
self.n_samples = n_samples
|
|
82
|
+
self.seed = seed
|
|
83
|
+
|
|
84
|
+
def evaluate(self, c0: Tensor, u: Tensor) -> Tensor:
|
|
85
|
+
"""Evaluate the OFV for a candidate control vector.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
c0:
|
|
90
|
+
Current state, shape ``(n_species,)``.
|
|
91
|
+
u:
|
|
92
|
+
Candidate control vector, shape ``(n_controls,)``.
|
|
93
|
+
Applied *constantly* over the horizon.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
Tensor
|
|
98
|
+
Scalar OFV value (lower = better).
|
|
99
|
+
"""
|
|
100
|
+
from perfusio.hybrid.forecast import forecast_run
|
|
101
|
+
|
|
102
|
+
if self.hybrid is None:
|
|
103
|
+
msg = "TargetTrackingOFV.evaluate() requires a hybrid model; pass hybrid= at construction."
|
|
104
|
+
raise RuntimeError(msg)
|
|
105
|
+
|
|
106
|
+
u_seq = u.unsqueeze(0).expand(self.horizon, -1)
|
|
107
|
+
preds = forecast_run(
|
|
108
|
+
self.hybrid,
|
|
109
|
+
c0,
|
|
110
|
+
u_seq,
|
|
111
|
+
horizon=self.horizon,
|
|
112
|
+
n_samples=self.n_samples,
|
|
113
|
+
seed=self.seed,
|
|
114
|
+
)
|
|
115
|
+
# Use posterior mean for OFV (Gadiyar Eq. 7)
|
|
116
|
+
mean_traj = preds["mean"] # (horizon, n_species)
|
|
117
|
+
|
|
118
|
+
ofv = torch.tensor(0.0, dtype=mean_traj.dtype)
|
|
119
|
+
for spec in self.targets:
|
|
120
|
+
k = spec.species_index
|
|
121
|
+
for j in range(self.horizon):
|
|
122
|
+
ofv = ofv + spec.weight * (mean_traj[j, k] - spec.target) ** 2
|
|
123
|
+
return ofv
|
|
124
|
+
|
|
125
|
+
def score_trajectories(self, Y: Tensor) -> Tensor:
|
|
126
|
+
"""Score a batch of pre-computed trajectories against targets.
|
|
127
|
+
|
|
128
|
+
Useful for evaluating acquisition function surrogates directly on GP
|
|
129
|
+
prediction samples without running the full hybrid model.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
Y:
|
|
134
|
+
Pre-computed trajectories, shape ``(B, horizon, n_species)``.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
Tensor
|
|
139
|
+
OFV values, shape ``(B,)`` — higher is better (negative SSE).
|
|
140
|
+
"""
|
|
141
|
+
total = torch.zeros(Y.shape[0], dtype=Y.dtype, device=Y.device)
|
|
142
|
+
for spec in self.targets:
|
|
143
|
+
vals = Y[:, :, spec.species_index] # (B, horizon)
|
|
144
|
+
sse = ((vals - spec.target) ** 2).mean(dim=-1) # (B,)
|
|
145
|
+
total = total - spec.weight * sse
|
|
146
|
+
return total
|
|
147
|
+
|
|
148
|
+
def evaluate_batch(self, c0: Tensor, u_batch: Tensor) -> Tensor:
|
|
149
|
+
"""Evaluate OFV for a batch of candidate controls.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
c0:
|
|
154
|
+
Current state, shape ``(n_species,)``.
|
|
155
|
+
u_batch:
|
|
156
|
+
Candidate controls, shape ``(B, n_controls)``.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
Tensor
|
|
161
|
+
OFV values, shape ``(B,)``.
|
|
162
|
+
"""
|
|
163
|
+
return torch.stack([self.evaluate(c0, u_batch[i]) for i in range(u_batch.shape[0])])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class MultiObjectiveOFV:
|
|
167
|
+
"""Vector-valued OFV for multi-objective BED.
|
|
168
|
+
|
|
169
|
+
Returns one OFV per objective (e.g. maximise titer AND minimise ammonium).
|
|
170
|
+
Used with multi-objective acquisition functions (qEHVI, qNEHVI, qNParEGO).
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
hybrid:
|
|
175
|
+
Fitted hybrid model.
|
|
176
|
+
objectives:
|
|
177
|
+
List of ``(species_index, target, direction)`` tuples where
|
|
178
|
+
``direction`` is ``+1`` for maximisation and ``-1`` for minimisation
|
|
179
|
+
(the sign is applied to the OFV before passing to botorch acqf).
|
|
180
|
+
horizon:
|
|
181
|
+
Prediction horizon.
|
|
182
|
+
n_samples:
|
|
183
|
+
MC rollout samples.
|
|
184
|
+
seed:
|
|
185
|
+
Random seed.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
hybrid: HybridStateSpaceModel,
|
|
191
|
+
objectives: list[tuple[int, float, float]],
|
|
192
|
+
horizon: int = 3,
|
|
193
|
+
n_samples: int = 100,
|
|
194
|
+
seed: int = 42,
|
|
195
|
+
) -> None:
|
|
196
|
+
self.hybrid = hybrid
|
|
197
|
+
self.objectives = objectives
|
|
198
|
+
self.horizon = horizon
|
|
199
|
+
self.n_samples = n_samples
|
|
200
|
+
self.seed = seed
|
|
201
|
+
|
|
202
|
+
def evaluate(self, c0: Tensor, u: Tensor) -> Tensor:
|
|
203
|
+
"""Return the multi-objective OFV vector for one candidate control.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
c0:
|
|
208
|
+
Current state, shape ``(n_species,)``.
|
|
209
|
+
u:
|
|
210
|
+
Candidate control, shape ``(n_controls,)``.
|
|
211
|
+
|
|
212
|
+
Returns
|
|
213
|
+
-------
|
|
214
|
+
Tensor
|
|
215
|
+
Shape ``(n_objectives,)``. Positive values = better for botorch
|
|
216
|
+
(all objectives are negated and then passed as maximisation).
|
|
217
|
+
"""
|
|
218
|
+
from perfusio.hybrid.forecast import forecast_run
|
|
219
|
+
|
|
220
|
+
u_seq = u.unsqueeze(0).expand(self.horizon, -1)
|
|
221
|
+
preds = forecast_run(
|
|
222
|
+
self.hybrid,
|
|
223
|
+
c0,
|
|
224
|
+
u_seq,
|
|
225
|
+
horizon=self.horizon,
|
|
226
|
+
n_samples=self.n_samples,
|
|
227
|
+
seed=self.seed,
|
|
228
|
+
)
|
|
229
|
+
mean_traj = preds["mean"] # (horizon, n_species)
|
|
230
|
+
|
|
231
|
+
vals = []
|
|
232
|
+
for k, target, direction in self.objectives:
|
|
233
|
+
# Sum of squared deviations (lower = better)
|
|
234
|
+
sse = sum((mean_traj[j, k] - target) ** 2 for j in range(self.horizon))
|
|
235
|
+
# Negate so that botorch maximises (smaller SSE → larger objective)
|
|
236
|
+
vals.append(-float(direction) * sse) # type: ignore[arg-type]
|
|
237
|
+
|
|
238
|
+
return torch.tensor(vals, dtype=mean_traj.dtype)
|