Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/tune/tune.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: plugboard.tune
72 changes: 72 additions & 0 deletions docs/examples/tutorials/tuning-a-process.md
Original file line number Diff line number Diff line change
@@ -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<br>**horizontal** } --> trajectory@{ shape: rounded, label: Trajectory<br>**trajectory** }
trajectory@{ shape: rounded, label: Trajectory<br>**trajectory** } --> max-height@{ shape: rounded, label: MaxHeight<br>**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.
124 changes: 124 additions & 0 deletions examples/tutorials/006_optimisation/hello_tuner.py
Original file line number Diff line number Diff line change
@@ -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]
46 changes: 46 additions & 0 deletions examples/tutorials/006_optimisation/model-with-tuner.yaml
Original file line number Diff line number Diff line change
@@ -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

2 changes: 2 additions & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions plugboard/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +35,7 @@


__all__ = [
"CategoricalParameterSpec",
"ComponentSpec",
"ComponentArgsDict",
"ComponentArgsSpec",
Expand All @@ -44,6 +48,8 @@
"ConnectorSpec",
"Direction",
"Entity",
"FloatParameterSpec",
"IntParameterSpec",
"IODirection",
"ObjectiveSpec",
"OptunaSpec",
Expand Down
2 changes: 2 additions & 0 deletions plugboard/schemas/tune.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading