From c6762be2728b139a4bcc5352a4b78b3fa5b55aa4 Mon Sep 17 00:00:00 2001 From: Chris Sherman Date: Tue, 10 Dec 2024 15:44:29 +0000 Subject: [PATCH 1/4] Add optuna sensor manager --- pyproject.toml | 3 + stonesoup/sensormanager/optuna.py | 90 ++++++++++++++++++++ stonesoup/sensormanager/tests/test_optuna.py | 79 +++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 stonesoup/sensormanager/optuna.py create mode 100644 stonesoup/sensormanager/tests/test_optuna.py diff --git a/pyproject.toml b/pyproject.toml index 48db74fc9..01edb9fd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,9 @@ mfa = [ ehm = [ "pyehm", ] +optuna = [ + "optuna", +] [tool.setuptools] include-package-data = false diff --git a/stonesoup/sensormanager/optuna.py b/stonesoup/sensormanager/optuna.py new file mode 100644 index 000000000..521f01439 --- /dev/null +++ b/stonesoup/sensormanager/optuna.py @@ -0,0 +1,90 @@ +from typing import Iterable +from collections import defaultdict + +try: + import optuna +except ImportError as error: + raise ImportError("Usage of Optuna Sensor Manager requires that the optional package " + "`optuna`is installed") from error + +from ..base import Property +from ..sensor.sensor import Sensor +from .action import RealNumberActionGenerator, Action +from . import SensorManager + + +class OptunaSensorManager(SensorManager): + """Sensor Manager that uses the optuna package to determine the best actions available within + a time frame specified by :attr:`timeout`.""" + timeout: float = Property( + doc="Number of seconds that the sensor manager should optimise for each time-step", + default=10.) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + optuna.logging.set_verbosity(optuna.logging.CRITICAL) + + def choose_actions(self, tracks, timestamp, nchoose=1, **kwargs) -> Iterable[tuple[Sensor, + Action]]: + """Method to find the best actions for the given :attr:`sensors` to according to the + :attr:`reward_function`. + + Parameters + ---------- + tracks_list : List[Track] + List of Tracks for the sensor manager to observe. + timestamp: datetime.datetime + The time for the actions to be produced for. + + Returns + ------- + Iterable[Tuple[Sensor, Action]] + The actions and associated sensors produced by the sensor manager.""" + all_action_generators = dict() + + for sensor in self.sensors: + action_generators = sensor.actions(timestamp) + all_action_generators[sensor] = action_generators # set of generators + + def config_from_trial(trial): + config = defaultdict(list) + for i, (sensor, generators) in enumerate(all_action_generators.items()): + + for j, generator in enumerate(generators): + if isinstance(generator, RealNumberActionGenerator): + value = trial.suggest_float( + f'{i}{j}', generator.min, generator.max, + step=getattr(generator, 'resolution', None)) + else: + raise TypeError(f"type {type(generator)} not handled yet") + action = generator.action_from_value(value) + if action is not None: + config[sensor].append(action) + else: + config[sensor].append(generator.default_action) + return config + + def optimise_func(trial): + config = config_from_trial(trial) + + return -self.reward_function(config, tracks, timestamp) + + study = optuna.create_study() + # will finish study after `timeout` seconds has elapsed. + study.optimize(optimise_func, n_trials=None, timeout=self.timeout) + + best_params = study.best_params + config = defaultdict(list) + for i, (sensor, generators) in enumerate(all_action_generators.items()): + for j, generator in enumerate(generators): + if isinstance(generator, RealNumberActionGenerator): + action = generator.action_from_value(best_params[f'{i}{j}']) + else: + raise TypeError(f"generator type {type(generator)} not supported") + if action is not None: + config[sensor].append(action) + else: + config[sensor].append(generator.default_action) + + # Return mapping of sensors and chosen actions for sensors + return [config] diff --git a/stonesoup/sensormanager/tests/test_optuna.py b/stonesoup/sensormanager/tests/test_optuna.py new file mode 100644 index 000000000..e795f3800 --- /dev/null +++ b/stonesoup/sensormanager/tests/test_optuna.py @@ -0,0 +1,79 @@ +import copy +from collections import defaultdict +import pytest +from ordered_set import OrderedSet +import numpy as np + +try: + from ..optuna import OptunaSensorManager +except ImportError: + # Catch optional dependencies import error + pytest.skip( + "Skipping due to missing optional dependencies. Usage of Optuna Sensor Manager requires " + "that the optional package `optuna`is installed.", + allow_module_level=True + ) + +from ..reward import UncertaintyRewardFunction +from ...hypothesiser.distance import DistanceHypothesiser +from ...measures import Mahalanobis +from ...dataassociator.neighbour import GNNWith2DAssignment +from ...sensor.radar.radar import RadarRotatingBearingRange +from ...sensor.action.dwell_action import ChangeDwellAction + + +def test_optuna_manager(params): + predictor = params['predictor'] + updater = params['updater'] + sensor_set = params['sensor_set'] + timesteps = params['timesteps'] + tracks = params['tracks'] + truths = params['truths'] + + reward_function = UncertaintyRewardFunction(predictor, updater) + greedysensormanager = OptunaSensorManager(sensor_set, reward_function=reward_function, + timeout=0.1) + + hypothesiser = DistanceHypothesiser(predictor, updater, measure=Mahalanobis(), + missed_distance=5) + data_associator = GNNWith2DAssignment(hypothesiser) + + sensor_history = defaultdict(dict) + dwell_centres = dict() + + for timestep in timesteps[1:]: + chosen_actions = greedysensormanager.choose_actions(tracks, timestep) + measurements = set() + for chosen_action in chosen_actions: + for sensor, actions in chosen_action.items(): + sensor.add_actions(actions) + for sensor in sensor_set: + sensor.act(timestep) + sensor_history[timestep][sensor] = copy.copy(sensor) + dwell_centres[timestep] = sensor.dwell_centre[0][0] + measurements |= sensor.measure(OrderedSet(truth[timestep] for truth in truths), + noise=False) + hypotheses = data_associator.associate(tracks, + measurements, + timestep) + for track in tracks: + hypothesis = hypotheses[track] + if hypothesis.measurement: + post = updater.update(hypothesis) + track.append(post) + else: + track.append(hypothesis.prediction) + + # Double check choose_actions method types are as expected + assert isinstance(chosen_actions, list) + + for chosen_actions in chosen_actions: + for sensor, actions in chosen_action.items(): + assert isinstance(sensor, RadarRotatingBearingRange) + assert isinstance(actions[0], ChangeDwellAction) + + # Check sensor following track as expected + assert dwell_centres[timesteps[5]] - np.radians(135) < 1e-3 + assert dwell_centres[timesteps[15]] - np.radians(45) < 1e-3 + assert dwell_centres[timesteps[25]] - np.radians(-45) < 1e-3 + assert dwell_centres[timesteps[35]] - np.radians(-135) < 1e-3 From 5595441dad48260fd2cf3fe26eceee31eb00a223 Mon Sep 17 00:00:00 2001 From: Steven Hiscocks Date: Fri, 13 Dec 2024 11:10:13 +0000 Subject: [PATCH 2/4] Add optuna to tests in CircleCI --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0e2555e37..95f253159 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -28,7 +28,7 @@ jobs: python -m venv venv . venv/bin/activate pip install --upgrade pip - pip install -e .[dev,orbital] opencv-python-headless pyehm + pip install -e .[dev,ehm,optuna,orbital] opencv-python-headless - save_cache: paths: - ./venv @@ -75,7 +75,7 @@ jobs: python -m venv venv . venv/bin/activate pip install --upgrade pip - pip install -e .[orbital] opencv-python-headless plotly pytest-cov pytest-remotedata pytest-skip-slow pyehm confluent-kafka h5py pandas + pip install -e .[ehm,optuna,orbital] opencv-python-headless plotly pytest-cov pytest-remotedata pytest-skip-slow pyehm confluent-kafka h5py pandas - save_cache: paths: - ./venv From eb2b456be870b53104b318ffd60317a9e7e10364 Mon Sep 17 00:00:00 2001 From: Chris Sherman Date: Fri, 13 Dec 2024 14:42:56 +0000 Subject: [PATCH 3/4] Remove UserWarning due to search range --- stonesoup/sensormanager/optuna.py | 9 ++++++--- stonesoup/sensormanager/tests/test_optuna.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/stonesoup/sensormanager/optuna.py b/stonesoup/sensormanager/optuna.py index 521f01439..5e66caea4 100644 --- a/stonesoup/sensormanager/optuna.py +++ b/stonesoup/sensormanager/optuna.py @@ -1,5 +1,6 @@ from typing import Iterable from collections import defaultdict +import warnings try: import optuna @@ -52,9 +53,11 @@ def config_from_trial(trial): for j, generator in enumerate(generators): if isinstance(generator, RealNumberActionGenerator): - value = trial.suggest_float( - f'{i}{j}', generator.min, generator.max, - step=getattr(generator, 'resolution', None)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + value = trial.suggest_float( + f'{i}{j}', generator.min, generator.max + generator.epsilon, + step=getattr(generator, 'resolution', None)) else: raise TypeError(f"type {type(generator)} not handled yet") action = generator.action_from_value(value) diff --git a/stonesoup/sensormanager/tests/test_optuna.py b/stonesoup/sensormanager/tests/test_optuna.py index e795f3800..e8568af3c 100644 --- a/stonesoup/sensormanager/tests/test_optuna.py +++ b/stonesoup/sensormanager/tests/test_optuna.py @@ -31,7 +31,7 @@ def test_optuna_manager(params): truths = params['truths'] reward_function = UncertaintyRewardFunction(predictor, updater) - greedysensormanager = OptunaSensorManager(sensor_set, reward_function=reward_function, + optunasensormanager = OptunaSensorManager(sensor_set, reward_function=reward_function, timeout=0.1) hypothesiser = DistanceHypothesiser(predictor, updater, measure=Mahalanobis(), @@ -42,7 +42,7 @@ def test_optuna_manager(params): dwell_centres = dict() for timestep in timesteps[1:]: - chosen_actions = greedysensormanager.choose_actions(tracks, timestep) + chosen_actions = optunasensormanager.choose_actions(tracks, timestep) measurements = set() for chosen_action in chosen_actions: for sensor, actions in chosen_action.items(): From 005e7f0be03f9c1cd9e84af6e39c5b75dacafab1 Mon Sep 17 00:00:00 2001 From: Chris Sherman Date: Mon, 6 Jan 2025 14:53:48 +0000 Subject: [PATCH 4/4] add action_from_value abstractmethod to RealNumberActionGenerator --- stonesoup/sensormanager/action.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stonesoup/sensormanager/action.py b/stonesoup/sensormanager/action.py index 7d935b3a2..a6bce68ee 100644 --- a/stonesoup/sensormanager/action.py +++ b/stonesoup/sensormanager/action.py @@ -93,6 +93,10 @@ def min(self): def max(self): raise NotImplementedError + @abstractmethod + def action_from_value(self): + raise NotImplementedError + class ActionableProperty(Property): """Property that is modified via an :class:`~.Action` with defined, non-equal start and end