diff --git a/docs/api/tune/tune.md b/docs/api/tune/tune.md new file mode 100644 index 00000000..512f1c7e --- /dev/null +++ b/docs/api/tune/tune.md @@ -0,0 +1 @@ +::: plugboard.tune diff --git a/docs/examples/tutorials/tuning-a-process.md b/docs/examples/tutorials/tuning-a-process.md new file mode 100644 index 00000000..071f76d9 --- /dev/null +++ b/docs/examples/tutorials/tuning-a-process.md @@ -0,0 +1,72 @@ +--- +tags: + - optimisation +--- +Once you have built a model of your process, a common problem you might face is tuning its parameters. Plugboard includes a built-in optimisation utility based on [Ray Tune](https://docs.ray.io/en/latest/tune/index.html) and [Optuna](https://optuna.org/). Using this tool you can do things like: + +* Calibrate the parameters of a process model to match observed results; and +* Optimise a process model to maximise or minimise its output. + +These capabilities are particularly useful when working with digital twins: for example given a model of a production line, you could use the tuner to work out how to maximise its output. + +!!! tip + By using Ray Tune, Plugboard allows you to run optimisations in parallel within a Ray cluster, allowing you to explore the parameter space quickly even when working with long simulations. + +## Define a model to optimise + +As a simple example, we'll create a simple 3-component model to calculate the maximum height of a [projectile](https://en.wikipedia.org/wiki/Projectile_motion#Displacement) launched at a given angle and velocity. +```mermaid + flowchart LR + horizontal@{ shape: rounded, label: Iterator
**horizontal** } --> trajectory@{ shape: rounded, label: Trajectory
**trajectory** } + trajectory@{ shape: rounded, label: Trajectory
**trajectory** } --> max-height@{ shape: rounded, label: MaxHeight
**max-height** } +``` + +Running the model with different values of the angle and velocity parameters configured on the `Trajectory` component will result in different heights being found on the `MaxHeight` component at the end of the simulation. We will use the [`Tuner`][plugboard.tune.Tuner] class to explore this parameter space and maximise the projectile height. + +### Setting up the components + +We'll need the following components to implement the model above: +```python +--8<-- "examples/tutorials/006_optimisation/hello_tuner.py:components" +``` + +Instead of building a [`Process`][plugboard.process.Process] as we would normally do to run the model directly, we'll instead define the [`ProcessSpec`][plugboard.schemas.ProcessSpec] for the model. +```python +--8<-- "examples/tutorials/006_optimisation/hello_tuner.py:define_process" +``` + +## Setting up the Tuner + +Next, we set up a [`Tuner`][plugboard.tune.Tuner] object by configuring the `angle` and `velocity` arguments as floating point parameters, along with constraints. + +!!! info + Plugboard supports floating point, integer and categorical variables as tunable model parameters. See the definition of [`ParameterSpec`][plugboard.schemas.ParameterSpec] for details. + +When building the tuner, we also specify the number of optimisation samples and how many we will allow to run in parallel on Ray. +```python +--8<-- "examples/tutorials/006_optimisation/hello_tuner.py:run_tuner" +``` + +1. Set the objective, i.e. what we want our optimisation to target. In this case it is a field on the `max-height` component. This can be a list of objectives if you need to do multi-objective optimisation. +2. List the tunable parameters here. The `field_type` can be `"arg"` or `"initial_value"`. This is also where you can specify constraints on the parameters. +3. Set the number of trials to run. More trials will take longer, but may get closer to finding the true optimum. +4. The level of concurrency to use in Ray. +5. Whether to minimise or maximise the objective. This must be set as a list for multi-objective optimisation. + +Running this code will execute an optimisation job and print out information on each trial, along with the final optimisation result. + +!!! tip + Since [Optuna](https://optuna.org/) is used under the hood, you can configure the optional `algorithm` argument on the `Tuner` with additional configuration defined in [`OptunaSpec`][plugboard.schemas.OptunaSpec]. For example, the [`storage`](https://optuna.readthedocs.io/en/stable/reference/storages.html) argument allows you to save the optimisation results to a database or SQLite file. You can then use a tool like [Optuna Dashboard](https://optuna-dashboard.readthedocs.io/en/stable/getting-started.html) to study the optimisation output in more detail. + +## Using YAML config + +Plugboard's YAML config supports an optional `tune` section, allowing you to define optimisation jobs alongside your model configuration: +```yaml +--8<-- "examples/tutorials/006_optimisation/model-with-tuner.yaml" +``` + +1. As usual, this section defines the [`Process`][plugboard.process.Process]. It can also be replaced by a path to another YAML file. +2. This section is optional, and configures the [`Tuner`][plugboard.tune.Tuner]. +3. Parameters need to reference a type, so that Plugboard knows the type of parameter to build. + +Now run `plugboard process tune model-with-tuner.yaml` to execute the optimisation job from the CLI. diff --git a/examples/tutorials/006_optimisation/hello_tuner.py b/examples/tutorials/006_optimisation/hello_tuner.py new file mode 100644 index 00000000..1bdb51ad --- /dev/null +++ b/examples/tutorials/006_optimisation/hello_tuner.py @@ -0,0 +1,124 @@ +"""Optimisation demonstration.""" + +# fmt: off +import typing as _t + +from plugboard.component import Component, IOController as IO +from plugboard.process import ProcessBuilder +from plugboard.schemas import ComponentArgsDict, ProcessSpec, ProcessArgsSpec, ObjectiveSpec +from plugboard.schemas.tune import FloatParameterSpec +from plugboard.tune import Tuner +import math + + +# --8<-- [start:components] +class Iterator(Component): + """Creates a sequence of x values.""" + + io = IO(outputs=["x"]) + + def __init__(self, iters: int, **kwargs: _t.Unpack[ComponentArgsDict]) -> None: + super().__init__(**kwargs) + self._iters = iters + + async def init(self) -> None: + self._seq = iter(range(self._iters)) + + async def step(self) -> None: + try: + self.x = next(self._seq) + except StopIteration: + await self.io.close() + + +class Trajectory(Component): + """Computes the height of a projectile.""" + + io = IO(inputs=["x"], outputs=["y"]) + + def __init__( + self, angle: float = 30, velocity: float = 20, **kwargs: _t.Unpack[ComponentArgsDict] + ) -> None: + super().__init__(**kwargs) + self._angle_radians = math.radians(angle) + self._v0 = velocity + + async def step(self) -> None: + self._logger.info("Calculating trajectory", x=self.x) + self.y = self.x * math.tan(self._angle_radians) - (9.81 * self.x**2) / ( + 2 * self._v0**2 * math.cos(self._angle_radians) ** 2 + ) + + +class MaxHeight(Component): + """Record the maximum height achieved.""" + + io = IO(inputs=["y"], outputs=["max_y"]) + + def __init__(self, **kwargs: _t.Unpack[ComponentArgsDict]) -> None: + super().__init__(**kwargs) + self.max_y: float = 0 + + async def step(self) -> None: + self.max_y = max(self.y, self.max_y) +# --8<-- [end:components] + + +if __name__ == "__main__": + # --8<-- [start:define_process] + process_spec = ProcessSpec( + args=ProcessArgsSpec( + components=[ + {"type": "hello_tuner.Iterator", "args": {"name": "horizontal", "iters": 100}}, + { + "type": "hello_tuner.Trajectory", + "args": {"name": "trajectory", "angle": 30, "velocity": 20}, + }, + {"type": "hello_tuner.MaxHeight", "args": {"name": "max-height"}}, + ], + connectors=[ + {"source": "horizontal.x", "target": "trajectory.x"}, + {"source": "trajectory.y", "target": "max-height.y"}, + ], + ), + type="plugboard.process.LocalProcess", + ) + # Check that the process spec can be built + _ = ProcessBuilder.build(spec=process_spec) + # --8<-- [end:define_process] + # --8<-- [start:run_tuner] + tuner = Tuner( + objective=ObjectiveSpec( # (1)! + object_type="component", + object_name="max-height", + field_type="field", + field_name="max_y", + ), + parameters=[ + FloatParameterSpec( # (2)! + object_type="component", + object_name="trajectory", + field_type="arg", + field_name="angle", + lower=0, + upper=90, + ), + FloatParameterSpec( + object_type="component", + object_name="trajectory", + field_type="arg", + field_name="velocity", + lower=0, + upper=100, + ), + ], + num_samples=40, # (3)! + max_concurrent=4, # (4)! + mode="max", # (5)! + ) + result = tuner.run(spec=process_spec) + print( + f"Best parameters: angle={result.config['trajectory.angle']}, velocity={result.config['trajectory.velocity']}" + ) + print(f"Best max height: {result.metrics['max-height.max_y']}") + # --8<-- [end:run_tuner] diff --git a/examples/tutorials/006_optimisation/model-with-tuner.yaml b/examples/tutorials/006_optimisation/model-with-tuner.yaml new file mode 100644 index 00000000..00509e64 --- /dev/null +++ b/examples/tutorials/006_optimisation/model-with-tuner.yaml @@ -0,0 +1,46 @@ +plugboard: + process: # (1)! + args: + components: + - type: hello_tuner.Iterator + args: + name: horizontal + iters: 100 + - type: hello_tuner.Trajectory + args: + name: trajectory + angle: 25 + velocity: 20 + - type: hello_tuner.MaxHeight + args: + name: max-height + connectors: + - source: horizontal.x + target: trajectory.x + - source: trajectory.y + target: max-height.y + tune: # (2)! + args: + objective: + object_name: max-height + field_type: field + field_name: max_y + parameters: + - type: ray.tune.uniform # (3)! + object_type: component + object_name: trajectory + field_type: arg + field_name: angle + lower: 0 + upper: 90 + - type: ray.tune.uniform + object_type: component + object_name: trajectory + field_type: arg + field_name: velocity + lower: 0 + upper: 100 + num_samples: 40 + mode: max + max_concurrent: 4 + \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index 3a16c115..5a25aa30 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -113,6 +113,7 @@ nav: - More components: examples/tutorials/more-components.md - Running in parallel: examples/tutorials/running-in-parallel.md - Event-driven models: examples/tutorials/event-driven-models.md + - Tuning a process: examples/tutorials/tuning-a-process.md - Configuration: usage/configuration.md - Topics: usage/topics.md - Demos: @@ -133,6 +134,7 @@ nav: - process: api/process/process.md - schemas: api/schemas/schemas.md - state: api/state/state.md + - tune: api/tune/tune.md - utils: - api/utils/index.md - settings: api/utils/settings/settings.md diff --git a/plugboard/schemas/__init__.py b/plugboard/schemas/__init__.py index 0c897829..7a1c2a1a 100644 --- a/plugboard/schemas/__init__.py +++ b/plugboard/schemas/__init__.py @@ -21,7 +21,10 @@ from .process import ProcessArgsDict, ProcessArgsSpec, ProcessSpec from .state import StateBackendArgsDict, StateBackendArgsSpec, StateBackendSpec from .tune import ( + CategoricalParameterSpec, Direction, + FloatParameterSpec, + IntParameterSpec, ObjectiveSpec, OptunaSpec, ParameterSpec, @@ -32,6 +35,7 @@ __all__ = [ + "CategoricalParameterSpec", "ComponentSpec", "ComponentArgsDict", "ComponentArgsSpec", @@ -44,6 +48,8 @@ "ConnectorSpec", "Direction", "Entity", + "FloatParameterSpec", + "IntParameterSpec", "IODirection", "ObjectiveSpec", "OptunaSpec", diff --git a/plugboard/schemas/tune.py b/plugboard/schemas/tune.py index c0564a6c..72527ea8 100644 --- a/plugboard/schemas/tune.py +++ b/plugboard/schemas/tune.py @@ -118,8 +118,10 @@ class CategoricalParameterSpec(BaseFieldSpec): IntParameterSpec, CategoricalParameterSpec, ] +"""A union type for all parameter specifications.""" Direction = _t.Literal["min", "max"] +"""A type for the direction of optimisation.""" class TuneArgsDict(_t.TypedDict):