From 4cb5c88cd426910dc503f64f38b496f5cc5e01af Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Tue, 17 Jun 2025 21:10:34 +0100 Subject: [PATCH 1/7] Optimisation tutorial code --- .../tutorials/005_optimisation/hello_tuner.py | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 examples/tutorials/005_optimisation/hello_tuner.py diff --git a/examples/tutorials/005_optimisation/hello_tuner.py b/examples/tutorials/005_optimisation/hello_tuner.py new file mode 100644 index 00000000..697866a2 --- /dev/null +++ b/examples/tutorials/005_optimisation/hello_tuner.py @@ -0,0 +1,120 @@ +"""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"]) + + async def step(self) -> None: + self.max_y = max(self.y, self.max_y if self.max_y is not None else float("-inf")) # type: ignore[has-type] +# --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] From c02e5e28f18a2a2fda5c1f5aaaab4a733718b584 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Tue, 17 Jun 2025 21:50:22 +0100 Subject: [PATCH 2/7] With config file --- .../tutorials/005_optimisation/hello_tuner.py | 6 +-- .../005_optimisation/model-with-tuner.yaml | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 examples/tutorials/005_optimisation/model-with-tuner.yaml diff --git a/examples/tutorials/005_optimisation/hello_tuner.py b/examples/tutorials/005_optimisation/hello_tuner.py index 697866a2..0c5fcb85 100644 --- a/examples/tutorials/005_optimisation/hello_tuner.py +++ b/examples/tutorials/005_optimisation/hello_tuner.py @@ -70,11 +70,11 @@ async def step(self) -> None: "type": "hello_tuner.Trajectory", "args": {"name": "trajectory", "angle": 30, "velocity": 20}, }, - {"type": "hello_tuner.MaxHeight", "args": {"name": "max_height"}}, + {"type": "hello_tuner.MaxHeight", "args": {"name": "max-height"}}, ], connectors=[ {"source": "horizontal.x", "target": "trajectory.x"}, - {"source": "trajectory.y", "target": "max_height.y"}, + {"source": "trajectory.y", "target": "max-height.y"}, ], ), type="plugboard.process.LocalProcess", @@ -86,7 +86,7 @@ async def step(self) -> None: tuner = Tuner( objective=ObjectiveSpec( # (1)! object_type="component", - object_name="max_height", + object_name="max-height", field_type="field", field_name="max_y", ), diff --git a/examples/tutorials/005_optimisation/model-with-tuner.yaml b/examples/tutorials/005_optimisation/model-with-tuner.yaml new file mode 100644 index 00000000..5e49b796 --- /dev/null +++ b/examples/tutorials/005_optimisation/model-with-tuner.yaml @@ -0,0 +1,46 @@ +plugboard: + process: # (1)! + args: + components: + - type: hello_tuner.Iterator + args: + name: horizonal + iters: 100 + - type: hello_tuner.Trajectory + args: + name: trajectory + angle: 25 + velocity: 20 + - type: hello_tuner.MaxHeight + args: + name: max-height + connectors: + - source: horizonal.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 From adb9a9fac04334c4e48ac0dc76e34f9a906c6ec6 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Tue, 17 Jun 2025 21:53:20 +0100 Subject: [PATCH 3/7] Add md file for tutorial --- docs/examples/tutorials/tuning-a-process.md | 5 +++++ mkdocs.yaml | 1 + 2 files changed, 6 insertions(+) create mode 100644 docs/examples/tutorials/tuning-a-process.md diff --git a/docs/examples/tutorials/tuning-a-process.md b/docs/examples/tutorials/tuning-a-process.md new file mode 100644 index 00000000..cd74bbfc --- /dev/null +++ b/docs/examples/tutorials/tuning-a-process.md @@ -0,0 +1,5 @@ +--- +tags: + - optimisation +--- +TODO: Write tutorial. \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index 3a16c115..f6174741 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: From 6963d999443ac5d366f98c4ed584e07c8eb6d73a Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Wed, 18 Jun 2025 20:04:41 +0100 Subject: [PATCH 4/7] Add Tuner to API docs --- docs/api/tune/tune.md | 1 + mkdocs.yaml | 1 + plugboard/schemas/__init__.py | 6 ++++++ plugboard/schemas/tune.py | 2 ++ 4 files changed, 10 insertions(+) create mode 100644 docs/api/tune/tune.md 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/mkdocs.yaml b/mkdocs.yaml index f6174741..5a25aa30 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -134,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): From dd7154687e5eb976057b80460ee8de95201d10de Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Wed, 18 Jun 2025 20:46:58 +0100 Subject: [PATCH 5/7] Finish tutorial --- docs/examples/tutorials/tuning-a-process.md | 69 ++++++++++++++++++- .../hello_tuner.py | 2 +- .../model-with-tuner.yaml | 0 3 files changed, 69 insertions(+), 2 deletions(-) rename examples/tutorials/{005_optimisation => 006_optimisation}/hello_tuner.py (98%) rename examples/tutorials/{005_optimisation => 006_optimisation}/model-with-tuner.yaml (100%) diff --git a/docs/examples/tutorials/tuning-a-process.md b/docs/examples/tutorials/tuning-a-process.md index cd74bbfc..0482c52d 100644 --- a/docs/examples/tutorials/tuning-a-process.md +++ b/docs/examples/tutorials/tuning-a-process.md @@ -2,4 +2,71 @@ tags: - optimisation --- -TODO: Write tutorial. \ No newline at end of file +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 + horizonal@{ shape: rounded, label: Iterator
**horizonal** } --> 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/005_optimisation/hello_tuner.py b/examples/tutorials/006_optimisation/hello_tuner.py similarity index 98% rename from examples/tutorials/005_optimisation/hello_tuner.py rename to examples/tutorials/006_optimisation/hello_tuner.py index 0c5fcb85..1e51ae7a 100644 --- a/examples/tutorials/005_optimisation/hello_tuner.py +++ b/examples/tutorials/006_optimisation/hello_tuner.py @@ -116,5 +116,5 @@ async def step(self) -> None: 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']}") + print(f"Best max height: {result.metrics['max-height.max_y']}") # --8<-- [end:run_tuner] diff --git a/examples/tutorials/005_optimisation/model-with-tuner.yaml b/examples/tutorials/006_optimisation/model-with-tuner.yaml similarity index 100% rename from examples/tutorials/005_optimisation/model-with-tuner.yaml rename to examples/tutorials/006_optimisation/model-with-tuner.yaml From bc27eb445fde716c83dbb292fc89dbeacabced5f Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Wed, 18 Jun 2025 20:54:00 +0100 Subject: [PATCH 6/7] Fix typo --- docs/examples/tutorials/tuning-a-process.md | 2 +- examples/tutorials/006_optimisation/model-with-tuner.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/examples/tutorials/tuning-a-process.md b/docs/examples/tutorials/tuning-a-process.md index 0482c52d..071f76d9 100644 --- a/docs/examples/tutorials/tuning-a-process.md +++ b/docs/examples/tutorials/tuning-a-process.md @@ -17,7 +17,7 @@ These capabilities are particularly useful when working with digital twins: for 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 - horizonal@{ shape: rounded, label: Iterator
**horizonal** } --> trajectory@{ shape: rounded, label: Trajectory
**trajectory** } + 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** } ``` diff --git a/examples/tutorials/006_optimisation/model-with-tuner.yaml b/examples/tutorials/006_optimisation/model-with-tuner.yaml index 5e49b796..00509e64 100644 --- a/examples/tutorials/006_optimisation/model-with-tuner.yaml +++ b/examples/tutorials/006_optimisation/model-with-tuner.yaml @@ -4,7 +4,7 @@ plugboard: components: - type: hello_tuner.Iterator args: - name: horizonal + name: horizontal iters: 100 - type: hello_tuner.Trajectory args: @@ -15,7 +15,7 @@ plugboard: args: name: max-height connectors: - - source: horizonal.x + - source: horizontal.x target: trajectory.x - source: trajectory.y target: max-height.y From 494d11964a26ba11f4657120065eed8e97826822 Mon Sep 17 00:00:00 2001 From: Toby Coleman Date: Wed, 18 Jun 2025 20:56:27 +0100 Subject: [PATCH 7/7] Resolve typing ignore --- examples/tutorials/006_optimisation/hello_tuner.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/tutorials/006_optimisation/hello_tuner.py b/examples/tutorials/006_optimisation/hello_tuner.py index 1e51ae7a..1bdb51ad 100644 --- a/examples/tutorials/006_optimisation/hello_tuner.py +++ b/examples/tutorials/006_optimisation/hello_tuner.py @@ -55,8 +55,12 @@ class MaxHeight(Component): 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 if self.max_y is not None else float("-inf")) # type: ignore[has-type] + self.max_y = max(self.y, self.max_y) # --8<-- [end:components]