Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
a7d59aa
Add ray tune, optuna
toby-coleman Apr 5, 2025
12e2a64
Schema for optimisation
toby-coleman Apr 6, 2025
0e52b32
Missing docstring
toby-coleman Apr 6, 2025
bb77a2d
Reorder attributes
toby-coleman Apr 6, 2025
865c914
Start implementation of Tuner
toby-coleman Apr 6, 2025
24adb5a
Partial implementation
toby-coleman Apr 8, 2025
7bcdc9a
Build parameters
toby-coleman Apr 8, 2025
f24caf5
Update build_parameter
toby-coleman Apr 8, 2025
a8feb30
Merge remote-tracking branch 'origin/main' into feat/optimisation
toby-coleman Apr 13, 2025
cd08f0e
Update pyproject/lock file
toby-coleman Apr 13, 2025
4bae1a5
Upgrade lockfile
toby-coleman Apr 13, 2025
9cca2be
Initial override work
toby-coleman Apr 15, 2025
7e2edff
Change to BaseFieldSpec
toby-coleman Apr 27, 2025
a7ea30d
FieldSpec no longer ABC
toby-coleman Apr 27, 2025
58647e4
Update objective schemas
toby-coleman Apr 29, 2025
5f5c07e
Revert changes to schemas
toby-coleman Apr 29, 2025
7d24e62
Tune implementation
toby-coleman May 5, 2025
f40bb02
Add some logging
toby-coleman May 5, 2025
509e603
Rename object -> object_type
toby-coleman May 5, 2025
59e8267
Change default on objective spec
toby-coleman May 5, 2025
9ca2d2a
Merge remote-tracking branch 'origin/main' into feat/optimisation
toby-coleman May 5, 2025
9b5d790
Add optuna dependency
toby-coleman May 5, 2025
35b5377
Basic test passing
toby-coleman May 5, 2025
e91e098
Make run sync
toby-coleman May 5, 2025
61fac58
Refactor async runner
toby-coleman May 5, 2025
b161ab6
Fail test on trial failures
toby-coleman May 5, 2025
2cec8b2
Test optimisation result, component registry issues
toby-coleman May 5, 2025
a5cf152
Add optuna to test requirements
toby-coleman May 5, 2025
3d149f9
Add ray tune to test requirements
toby-coleman May 5, 2025
f525aec
Test both directions
toby-coleman May 5, 2025
ced66ec
Improve ComponentRegistry handling
toby-coleman May 5, 2025
7ead8c1
Delete unused code
toby-coleman May 5, 2025
c168041
Test tune one ray - requires namespace on RayProcess
toby-coleman May 17, 2025
3cdbaab
Add no cover
toby-coleman May 17, 2025
a5ccb43
More no cover
toby-coleman May 17, 2025
14b0c60
Merge remote-tracking branch 'origin/main' into feat/optimisation
toby-coleman May 17, 2025
dd6a857
Upgrade ray version
toby-coleman May 17, 2025
fd5ce8b
Recreate lock file
toby-coleman May 17, 2025
8edec11
Try again
toby-coleman May 17, 2025
804d04c
Reinstate version from main
toby-coleman May 17, 2025
9fe2d99
Try again
toby-coleman May 17, 2025
92834f0
Update type ignore
toby-coleman May 17, 2025
6b6beb1
Test multi-objective case
toby-coleman May 18, 2025
5ea8b28
Update test
toby-coleman May 19, 2025
9dc7d57
Unit tests for Tuner
toby-coleman May 19, 2025
3995607
List of results for multi-objective
toby-coleman May 19, 2025
710a98b
Additional tests for schemas
toby-coleman May 26, 2025
045467b
Fix type
toby-coleman May 26, 2025
772e771
Improve coverage
toby-coleman May 26, 2025
0e2aa56
Add CLI command
toby-coleman May 26, 2025
04cd715
Fix typing issue
toby-coleman May 26, 2025
1a87923
Support load of config from file
toby-coleman May 26, 2025
de62c93
Nocover on field_type
toby-coleman May 26, 2025
500aae6
Update namespace
toby-coleman May 28, 2025
875fd8c
Fix error in type
toby-coleman Jun 3, 2025
76eaaaf
Simplify import
toby-coleman Jun 3, 2025
b7c7dd8
Comment on default concurrency
toby-coleman Jun 3, 2025
397592d
Fix typo
toby-coleman Jun 3, 2025
ab96436
Apply suggestions from code review
toby-coleman Jun 8, 2025
7bd476b
Revert change
toby-coleman Jun 8, 2025
f1424ae
Increase samples on test
toby-coleman Jun 9, 2025
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
58 changes: 58 additions & 0 deletions plugboard/cli/process/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from plugboard.diagram import MermaidDiagram
from plugboard.process import Process, ProcessBuilder
from plugboard.schemas import ConfigSpec
from plugboard.tune import Tuner
from plugboard.utils import add_sys_path


Expand All @@ -38,11 +39,39 @@
return process


def _build_tuner(config: ConfigSpec) -> Tuner:
tune_config = config.plugboard.tune
if tune_config is None:
stderr.print("[red]No tuning configuration found in YAML file[/red]")
raise typer.Exit(1)

Check warning on line 46 in plugboard/cli/process/__init__.py

View check run for this annotation

Codecov / codecov/patch

plugboard/cli/process/__init__.py#L45-L46

Added lines #L45 - L46 were not covered by tests
return Tuner(
objective=tune_config.args.objective,
parameters=tune_config.args.parameters,
num_samples=tune_config.args.num_samples,
mode=tune_config.args.mode,
max_concurrent=tune_config.args.max_concurrent,
algorithm=tune_config.args.algorithm,
)


async def _run_process(process: Process) -> None:
async with process:
await process.run()


def _run_tune(tuner: Tuner, config_spec: ConfigSpec) -> None:
process_spec = config_spec.plugboard.process
# Build the process to import the component types
_build_process(config_spec)
result = tuner.run(spec=process_spec)
print("[green]Best parameters found:[/green]")
if isinstance(result, list):
for r in result:
print(f"Config: {r.config} - Metrics: {r.metrics}")

Check warning on line 70 in plugboard/cli/process/__init__.py

View check run for this annotation

Codecov / codecov/patch

plugboard/cli/process/__init__.py#L70

Added line #L70 was not covered by tests
else:
print(f"Config: {result.config} - Metrics: {result.metrics}")


@app.command()
def run(
config: Annotated[
Expand Down Expand Up @@ -73,6 +102,35 @@
progress.update(task, description=f"[green]Process complete[/green]")


@app.command()
def tune(
config: Annotated[
Path,
typer.Argument(
exists=True,
file_okay=True,
dir_okay=False,
writable=False,
readable=True,
resolve_path=True,
help="Path to the YAML configuration file.",
),
],
) -> None:
"""Optimise a Plugboard process by adjusting its tunable parameters."""
config_spec = _read_yaml(config)
tuner = _build_tuner(config_spec)

with Progress(
SpinnerColumn("arrow3"),
TextColumn("[progress.description]{task.description}"),
) as progress:
task = progress.add_task(f"Running tune job from {config}", total=None)
with add_sys_path(config.parent):
_run_tune(tuner, config_spec)
progress.update(task, description=f"[green]Tune job complete[/green]")


@app.command()
def diagram(
config: Annotated[
Expand Down
2 changes: 1 addition & 1 deletion plugboard/connector/ray_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

try:
import ray
except ImportError:
except ImportError: # pragma: no cover
pass

_AsyncioChannelActor = build_actor_wrapper(AsyncioChannel)
Expand Down
10 changes: 7 additions & 3 deletions plugboard/process/ray_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
from plugboard.connector import Connector
from plugboard.process.process import Process
from plugboard.state import RayStateBackend, StateBackend
from plugboard.utils import build_actor_wrapper, depends_on_optional, gather_except
from plugboard.utils import build_actor_wrapper, depends_on_optional, gather_except, gen_rand_str


try:
import ray
except ImportError:
except ImportError: # pragma: no cover
pass


Expand All @@ -40,6 +40,8 @@ def __init__(
parameters: Optional; Parameters for the `Process`.
state: Optional; `StateBackend` for the `Process`.
"""
# TODO: Replace with a namespace based on the job ID or similar
self._namespace = f"plugboard-{gen_rand_str(16)}"
self._component_actors = {
# Recreate components on remote actors
c.id: self._create_component_actor(c)
Expand All @@ -58,7 +60,9 @@ def _create_component_actor(self, component: Component) -> _t.Any:
name = component.id
args = component.export()["args"]
actor_cls = build_actor_wrapper(component.__class__)
return ray.remote(num_cpus=0, name=name)(actor_cls).remote(**args) # type: ignore
return ray.remote(num_cpus=0, name=name, namespace=self._namespace)( # type: ignore
actor_cls
).remote(**args)

async def _update_component_attributes(self) -> None:
"""Updates attributes on local components from remote actors."""
Expand Down
16 changes: 16 additions & 0 deletions plugboard/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
from .io import IODirection
from .process import ProcessArgsDict, ProcessArgsSpec, ProcessSpec
from .state import StateBackendArgsDict, StateBackendArgsSpec, StateBackendSpec
from .tune import (
Direction,
ObjectiveSpec,
OptunaSpec,
ParameterSpec,
TuneArgsDict,
TuneArgsSpec,
TuneSpec,
)


__all__ = [
Expand All @@ -33,13 +42,20 @@
"ConnectorMode",
"ConnectorSocket",
"ConnectorSpec",
"Direction",
"Entity",
"IODirection",
"ObjectiveSpec",
"OptunaSpec",
"ParameterSpec",
"ProcessConfigSpec",
"ProcessSpec",
"ProcessArgsDict",
"ProcessArgsSpec",
"StateBackendSpec",
"StateBackendArgsDict",
"StateBackendArgsSpec",
"TuneArgsDict",
"TuneArgsSpec",
"TuneSpec",
]
4 changes: 3 additions & 1 deletion plugboard/schemas/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@
class PlugboardBaseModel(BaseModel, ABC):
"""Custom base model for Plugboard schemas."""

model_config = ConfigDict(extra="forbid", populate_by_name=True, use_enum_values=True)
model_config = ConfigDict(
extra="forbid", populate_by_name=True, use_enum_values=True, validate_assignment=True
)
27 changes: 26 additions & 1 deletion plugboard/schemas/config.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,42 @@
"""Provides top-level `ConfigSpec` class for Plugboard configuration."""

from pathlib import Path
import typing as _t

import msgspec
from pydantic import field_validator

from plugboard.schemas._common import PlugboardBaseModel
from .process import ProcessSpec
from .tune import TuneSpec


class ProcessConfigSpec(PlugboardBaseModel):
"""A `ProcessSpec` within a Plugboard configuration.

Attributes:
process: A `ProcessSpec` that specifies the process.
process: A `ProcessSpec` that specifies the process, or a path to a YAML file containing the
process specification.
tune: Optional; A `TuneSpec` that specifies an optimisation configuration.
"""

process: ProcessSpec
tune: TuneSpec | None = None

@field_validator("process", mode="before")
@classmethod
def _auto_load_process(cls, value: _t.Any) -> _t.Any:
"""Automatically loads the process specification from a YAML file if a path is provided."""
if isinstance(value, str) and Path(value).exists():
with open(value, "rb") as f:
other_config = msgspec.yaml.decode(f.read())
try:
return other_config["plugboard"]["process"]
except KeyError:
raise ValueError(

Check warning on line 36 in plugboard/schemas/config.py

View check run for this annotation

Codecov / codecov/patch

plugboard/schemas/config.py#L35-L36

Added lines #L35 - L36 were not covered by tests
"The provided YAML file does not contain a Plugboard process specification."
)
return value


class ConfigSpec(PlugboardBaseModel):
Expand Down
175 changes: 175 additions & 0 deletions plugboard/schemas/tune.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Provides the `TuneSpec` class for configuring optimisation jobs."""

from abc import ABC
import typing as _t

from pydantic import Field, PositiveInt, model_validator

from plugboard.schemas._common import PlugboardBaseModel


class OptunaSpec(PlugboardBaseModel):
"""Specification for the Optuna configuration.

See: https://docs.ray.io/en/latest/tune/api/doc/ray.tune.search.optuna.OptunaSearch.html
and https://optuna.readthedocs.io/en/stable/reference/index.html for more information on the
Optuna configuration.

Attributes:
type: The algorithm type to load.
study_name: Optional; The name of the study.
storage: Optional; The storage URI to save the optimisation results to.
"""

type: _t.Literal["ray.tune.search.optuna.OptunaSearch"] = "ray.tune.search.optuna.OptunaSearch"
study_name: str | None = None
storage: str | None = None


class BaseFieldSpec(PlugboardBaseModel, ABC):
"""Base class for specifying fields within a Plugboard [`Process`][plugboard.process.Process].

These fields may be used as adjustable parameter inputs or as an optimisation objective.

Attributes:
object_type: The type of object on which the field is defined. Defaults to "component".
object_name: The name of the object on which the field is defined.
field_type: The type of field. This can be "arg", "initial_value", or "field".
field_name: The name of the field.
"""

object_type: _t.Literal["component"] = Field("component", exclude=True)
object_name: str = Field(..., exclude=True)
field_type: _t.Literal["arg", "initial_value", "field"] = Field(..., exclude=True)
field_name: str = Field(..., exclude=True)

@property
def full_name(self) -> str:
"""Returns the full name of the field, including the object name and field name."""
return f"{self.object_name}.{self.field_name}"


class ObjectiveSpec(BaseFieldSpec):
"""Specification for an objective field."""

@model_validator(mode="before")
@classmethod
def _fill_defaults(
cls, data: dict[str, _t.Any] | list[dict[str, _t.Any]]
) -> dict[str, _t.Any] | list[dict[str, _t.Any]]:
if isinstance(data, list):
# If the data is a list, skip because it is already a list of objectives
return data
if "field_type" not in data:
data["field_type"] = "field"
if data["field_type"] != "field": # pragma: no cover
raise ValueError("The field type must be 'field' for an objective specification.")
return data


class FloatParameterSpec(BaseFieldSpec):
"""Specification for a uniform float parameter.

See: https://docs.ray.io/en/latest/tune/api/search_space.html.

Attributes:
type: The type of the parameter.
lower: The lower bound of the parameter.
upper: The upper bound of the parameter.
"""

type: _t.Literal["ray.tune.uniform"] = "ray.tune.uniform"
lower: float
upper: float


class IntParameterSpec(BaseFieldSpec):
"""Specification for a uniform integer parameter.

See: https://docs.ray.io/en/latest/tune/api/search_space.html.

Attributes:
type: The type of the parameter.
lower: The lower bound of the parameter.
upper: The upper bound of the parameter.
"""

type: _t.Literal["ray.tune.randint"] = "ray.tune.randint"
lower: int
upper: int


class CategoricalParameterSpec(BaseFieldSpec):
"""Specification for a categorical parameter.

See: https://docs.ray.io/en/latest/tune/api/search_space.html.

Attributes:
type: The type of the parameter.
categories: The categories of the parameter.
"""

type: _t.Literal["ray.tune.choice"] = "ray.tune.choice"
categories: list[_t.Any]


ParameterSpec = _t.Union[
FloatParameterSpec,
IntParameterSpec,
CategoricalParameterSpec,
]

Direction = _t.Literal["min", "max"]


class TuneArgsDict(_t.TypedDict):
"""`TypedDict` of the [`Tuner`][plugboard.tune.Tuner] constructor arguments."""

objective: str | list[str]
parameters: list[ParameterSpec]
num_samples: int
mode: _t.NotRequired[Direction | list[Direction]]
max_concurrent: _t.NotRequired[int | None]
algorithm: OptunaSpec


class TuneArgsSpec(PlugboardBaseModel):
"""Specification of the arguments for the `Tune` class.

Attributes:
objective: The location of the objective(s) to optimise for in the `Process`.
parameters: The parameters to optimise over.
num_samples: The number of samples to draw during the optimisation.
mode: The mode of optimisation. For multi-objective optimisation, this should be a list
containing a direction for each objective.
max_concurrent: The maximum number of concurrent trials.
algorithm: The algorithm to use for the optimisation.
"""

objective: ObjectiveSpec | list[ObjectiveSpec]
parameters: list[ParameterSpec] = Field(min_length=1)
num_samples: PositiveInt
mode: Direction | list[Direction] = "max"
max_concurrent: PositiveInt | None = None
algorithm: _t.Union[OptunaSpec] = Field(OptunaSpec(), discriminator="type")

@model_validator(mode="after")
def _validate_model(self: _t.Self) -> _t.Self:
if isinstance(self.mode, list):
if not isinstance(self.objective, list):
raise ValueError(
"In multi-objective optimisation, both `mode` and `objective` must be lists."
)
if len(self.mode) != len(self.objective):
raise ValueError("The length of `mode` must match the length of `objective`.")
return self


class TuneSpec(PlugboardBaseModel):
"""Configuration for an optimisation job.

Attributes:
args: The arguments for the `Tune` job.
"""

args: TuneArgsSpec
6 changes: 6 additions & 0 deletions plugboard/tune/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Tune submodule for configuring optimisation jobs."""

from plugboard.tune.tune import Tuner


__all__ = ["Tuner"]
Loading
Loading