Skip to content

Commit 29fdf76

Browse files
authored
Merge pull request #289 from optimas-org/multitask_uses_id
Multi-task generator uses _id.
2 parents 4e15b4f + 07c4698 commit 29fdf76

6 files changed

Lines changed: 91 additions & 114 deletions

File tree

.github/workflows/unix-openmpi.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ jobs:
2828
- shell: bash -l {0}
2929
name: Install dependencies
3030
run: |
31+
conda install -c conda-forge "numpy<2.4" "pandas<3"
3132
conda install -c conda-forge pytorch-cpu
32-
conda install -c pytorch numpy pandas
3333
conda install -c conda-forge mpi4py openmpi=5.*
3434
pip install .[test]
3535
pip install git+https://github.com/campa-consortium/gest-api.git

.github/workflows/unix.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ jobs:
2828
- shell: bash -l {0}
2929
name: Install dependencies
3030
run: |
31+
conda install -c conda-forge "numpy<2.4" "pandas<3"
3132
conda install -c conda-forge pytorch-cpu
32-
conda install -c pytorch numpy pandas
3333
conda install -c conda-forge mpi4py mpich
3434
pip install .[test]
3535
pip install git+https://github.com/campa-consortium/gest-api.git

optimas/generators/ax/developer/multitask.py

Lines changed: 60 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858

5959
from optimas.generators.ax.base import AxGenerator
6060
from optimas.core import (
61-
TrialParameter,
6261
Task,
6362
Trial,
6463
TrialStatus,
@@ -154,9 +153,6 @@ class AxMultitaskGenerator(AxGenerator):
154153
VOCS object defining variables, objectives, constraints, and observables.
155154
lofi_task, hifi_task : Task
156155
The low- and high-fidelity tasks.
157-
analyzed_parameters : list of Parameter, optional
158-
List of parameters to analyze at each trial, but which are not
159-
optimization objectives. By default ``None``.
160156
use_cuda : bool, optional
161157
Whether to allow the generator to run on a CUDA GPU. By default
162158
``False``.
@@ -178,6 +174,8 @@ class AxMultitaskGenerator(AxGenerator):
178174
179175
"""
180176

177+
returns_id = True
178+
181179
def __init__(
182180
self,
183181
vocs: VOCS,
@@ -190,16 +188,6 @@ def __init__(
190188
model_save_period: Optional[int] = 5,
191189
model_history_dir: Optional[str] = "model_history",
192190
) -> None:
193-
194-
# As trial parameters these get written to history array
195-
# Ax trial_index and arm toegther locate a point
196-
# Multiple points (Optimas trials) can share the same Ax trial_index
197-
# vocs interface note: These are not part of vocs. They are only stored
198-
# to allow keeping track of them from previous runs.
199-
custom_trial_parameters = [
200-
TrialParameter("arm_name", "ax_arm_name", dtype="U32"),
201-
TrialParameter("ax_trial_id", "ax_trial_index", dtype=int),
202-
]
203191
self._check_inputs(vocs, lofi_task, hifi_task)
204192

205193
super().__init__(
@@ -210,7 +198,6 @@ def __init__(
210198
save_model=save_model,
211199
model_save_period=model_save_period,
212200
model_history_dir=model_history_dir,
213-
custom_trial_parameters=custom_trial_parameters,
214201
)
215202
self.lofi_task = lofi_task
216203
self.hifi_task = hifi_task
@@ -226,6 +213,10 @@ def __init__(
226213
self.gr_lofi = None
227214
self._experiment = self._create_experiment()
228215

216+
# Internal mapping: _id -> (arm_name, ax_trial_id, trial_type)
217+
self._id_mapping = {}
218+
self._next_id = 0
219+
229220
def get_gen_specs(
230221
self, sim_workers: int, run_params: Dict, sim_max: int
231222
) -> Dict:
@@ -285,11 +276,22 @@ def suggest(self, num_points: Optional[int]) -> List[dict]:
285276
if trial_param.name == "trial_type":
286277
point[trial_param.name] = trial_type
287278

288-
point["ax_trial_id"] = trial_index
289-
point["arm_name"] = arm.name
279+
# Generate unique _id and store mapping
280+
current_id = self._next_id
281+
self._id_mapping[current_id] = {
282+
"ax_trial_id": trial_index,
283+
"arm_name": arm.name,
284+
}
285+
point["_id"] = current_id
286+
self._next_id += 1
290287
points.append(point)
291288
return points
292289

290+
def _get_trial_mapping(self, gen_id: int) -> Tuple[int, str]:
291+
"""Get mapping information for a trial gen_id."""
292+
mapping = self._id_mapping[gen_id]
293+
return mapping["ax_trial_id"], mapping["arm_name"]
294+
293295
def ingest(self, results: List[dict]) -> None:
294296
"""Incorporate evaluated trials into experiment."""
295297
# reconstruct Optimas trials
@@ -304,60 +306,77 @@ def ingest(self, results: List[dict]) -> None:
304306
)
305307
trials.append(trial)
306308

309+
# Apply _id mapping to all trials before processing
310+
for trial in trials:
311+
if trial.gen_id is not None:
312+
if trial.gen_id not in self._id_mapping:
313+
raise ValueError(
314+
f"Trial has _id={trial.gen_id} which is not recognized by this generator."
315+
)
316+
trial.ax_trial_id, trial.arm_name = self._get_trial_mapping(
317+
trial.gen_id
318+
)
319+
307320
if self.gen_state == NOT_STARTED:
308321
self._incorporate_external_data(trials)
309322
else:
310323
self._complete_evaluations(trials)
311324

312325
def _incorporate_external_data(self, trials: List[Trial]) -> None:
313-
"""Incorporate external data (e.g., from history) into experiment."""
314-
# Get trial indices.
315-
trial_indices = []
316-
for trial in trials:
317-
trial_indices.append(trial.ax_trial_id)
318-
trial_indices = np.unique(np.array(trial_indices))
319-
320-
# Group trials by index.
321-
grouped_trials = {}
322-
for index in trial_indices:
323-
grouped_trials[index] = []
326+
"""Incorporate external data (e.g., from history) into experiment.
327+
328+
Unknown/external points have no gen_id. We create new arms and add
329+
observations directly to the experiment, then let the model use them
330+
as if starting fresh.
331+
"""
332+
# Group by trial_type (default to hifi if not specified)
333+
grouped_by_type = {}
324334
for trial in trials:
325-
grouped_trials[trial.ax_trial_id].append(trial)
326-
327-
# Add trials to experiment.
328-
for index in trial_indices:
329-
# Get all trials with current index.
330-
trials_i = grouped_trials[index]
331-
trial_type = trials_i[0].trial_type
332-
# Create arms.
335+
trial_type = getattr(trial, "trial_type", self.hifi_task.name)
336+
if trial_type not in grouped_by_type:
337+
grouped_by_type[trial_type] = []
338+
grouped_by_type[trial_type].append(trial)
339+
340+
param_to_name = {}
341+
arm_count = 0
342+
for trial_type, trials_i in grouped_by_type.items():
333343
arms = []
334344
for trial in trials_i:
335345
params = {}
336346
for var, val in zip(
337347
trial.varying_parameters, trial.parameter_values
338348
):
339349
params[var.name] = val
340-
arms.append(Arm(parameters=params, name=trial.arm_name))
350+
arm = Arm(parameters=params)
351+
if arm.signature not in param_to_name:
352+
param_to_name[arm.signature] = f"external_{arm_count}"
353+
arm_count += 1
354+
arms.append(
355+
Arm(parameters=params, name=param_to_name[arm.signature])
356+
)
357+
# self._next_id += 1
358+
341359
# Create new batch trial.
342360
gr = GeneratorRun(arms=arms, weights=[1.0] * len(arms))
343361
ax_trial = self._experiment.new_batch_trial(
344362
generator_run=gr, trial_type=trial_type
345363
)
346364
ax_trial.run()
347365
# Incorporate observations.
348-
for trial in trials_i:
366+
for i, trial in enumerate(trials_i):
367+
arm_name = ax_trial.arms[i].name
349368
if trial.status != TrialStatus.FAILED:
350369
objective_eval = {}
351370
oe = trial.objective_evaluations[0]
352371
objective_eval["f"] = (oe.value, oe.sem)
353-
ax_trial.run_metadata[trial.arm_name] = objective_eval
372+
ax_trial.run_metadata[arm_name] = objective_eval
354373
else:
355-
ax_trial.mark_arm_abandoned(trial.arm_name)
374+
ax_trial.mark_arm_abandoned(arm_name)
356375
# Mark batch trial as completed.
357376
ax_trial.mark_completed()
358377
# Keep track of high-fidelity trials.
359378
if trial_type == self.hifi_task.name:
360-
self.hifi_trials.append(index)
379+
self.hifi_trials.append(ax_trial.index)
361380

362381
def _complete_evaluations(self, trials: List[Trial]) -> None:
363382
"""Complete evaluated trials."""

optimas/generators/ax/service/ax_client.py

Lines changed: 25 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
from ax.service.ax_client import AxClient
66
from ax.core.objective import MultiObjective
7+
from ax.core.types import ComparisonOp
78

8-
from optimas.core import Objective, VaryingParameter, Parameter
9+
from optimas.core import Parameter
910
from gest_api.vocs import VOCS
1011
from .base import AxServiceGenerator
1112

@@ -16,17 +17,14 @@ class AxClientGenerator(AxServiceGenerator):
1617
This generator allows the user to provide a custom ``AxClient``,
1718
allowing for maximum control of the optimization.
1819
19-
For this generator there is no need to provide the list of
20-
``varying_parameters`` or ``objectives``. The generator will obtain
21-
these parameters directly from the ``AxClient``.
20+
For this generator there is no need to provide the ``vocs``. The
21+
generator builds a VOCS (variables, objectives, constraints, and
22+
observables) directly from the ``AxClient``.
2223
2324
Parameters
2425
----------
2526
ax_client : AxClient
2627
The Ax client from which the trials will be generated.
27-
analyzed_parameters : list of Parameter, optional
28-
List of parameters to analyze at each trial, but which are not
29-
optimization objectives. By default ``None``.
3028
abandon_failed_trials : bool, optional
3129
Whether failed trials should be abandoned (i.e., not suggested again).
3230
By default, ``True``.
@@ -52,19 +50,15 @@ class AxClientGenerator(AxServiceGenerator):
5250
5351
Notes
5452
-----
55-
If the ``AxClient`` contains ``outcome_constraints``, these will appear in
56-
the ``optimas`` log as optimization objectives. They are still being
57-
correctly used as constraints by the ``AxClient``, and the optimization
58-
will work as expected. This is only an issue on ``optimas``, which fails to
59-
properly recognize them because optimization constraints have not yet been
60-
implemented.
53+
Outcome constraints are passed into VOCS as constraints and are correctly
54+
used by the ``AxClient``. The ``optimas`` log/display does not yet show
55+
constraints separately; constraint metrics may appear as extra columns.
6156
6257
"""
6358

6459
def __init__(
6560
self,
6661
ax_client: AxClient,
67-
analyzed_parameters: Optional[List[Parameter]] = None,
6862
abandon_failed_trials: Optional[bool] = True,
6963
gpu_id: Optional[int] = 0,
7064
dedicated_resources: Optional[bool] = False,
@@ -74,12 +68,6 @@ def __init__(
7468
):
7569
# Create VOCS object from AxClient data
7670
vocs = self._create_vocs_from_ax_client(ax_client)
77-
78-
# Add constraints to analyzed parameters
79-
analyzed_parameters = self._add_constraints_to_analyzed_parameters(
80-
analyzed_parameters, ax_client
81-
)
82-
8371
use_cuda = self._use_cuda(ax_client)
8472
self._ax_client = ax_client
8573

@@ -113,65 +101,32 @@ def _create_vocs_from_ax_client(self, ax_client: AxClient) -> VOCS:
113101
obj_type = "MINIMIZE" if ax_obj.minimize else "MAXIMIZE"
114102
objectives[ax_obj.metric_names[0]] = obj_type
115103

116-
# Extract observables from outcome constraints (if any)
117-
observables = set()
104+
# Extract constraints from outcome constraints (if any)
105+
constraints = {}
118106
ax_config = ax_client.experiment.optimization_config
119107
if ax_config.outcome_constraints:
120108
for constraint in ax_config.outcome_constraints:
121-
observables.add(constraint.metric.name)
109+
name = constraint.metric.name
110+
if constraint.op == ComparisonOp.LEQ:
111+
constraints[name] = ["LESS_THAN", constraint.bound]
112+
elif constraint.op == ComparisonOp.GEQ:
113+
constraints[name] = ["GREATER_THAN", constraint.bound]
122114

123115
return VOCS(
124116
variables=variables,
125117
objectives=objectives,
126-
observables=observables,
118+
constraints=constraints,
127119
)
128120

129-
def _get_varying_parameters(self, ax_client: AxClient):
130-
"""Obtain the list of varying parameters from the AxClient."""
131-
varying_parameters = []
132-
for _, p in ax_client.experiment.search_space.parameters.items():
133-
vp = VaryingParameter(
134-
name=p.name,
135-
lower_bound=p.lower,
136-
upper_bound=p.upper,
137-
is_fidelity=p.is_fidelity,
138-
fidelity_target_value=p.target_value,
139-
dtype=p.python_type,
140-
)
141-
varying_parameters.append(vp)
142-
return varying_parameters
143-
144-
def _get_objectives(self, ax_client: AxClient):
145-
"""Obtain the list of objectives from the AxClient."""
146-
objectives = []
147-
ax_objective = ax_client.experiment.optimization_config.objective
148-
if isinstance(ax_objective, MultiObjective):
149-
ax_objectives = ax_objective.objectives
150-
else:
151-
ax_objectives = [ax_objective]
152-
for ax_obj in ax_objectives:
153-
obj = Objective(
154-
name=ax_obj.metric_names[0], minimize=ax_obj.minimize
155-
)
156-
objectives.append(obj)
157-
return objectives
158-
159-
def _add_constraints_to_analyzed_parameters(
160-
self, analyzed_parameters: List[Parameter], ax_client: AxClient
161-
):
162-
"""Add outcome constraints to the list of analyzed parameters.
163-
164-
This is currently needed because optimas does not yet have a
165-
proper definition of constraints. The constraints will be correctly
166-
handled and given to the AxClient, but will appear as analyzed
167-
parameters in the optimization log.
168-
"""
169-
ax_config = ax_client.experiment.optimization_config
170-
if ax_config.outcome_constraints and analyzed_parameters is None:
171-
analyzed_parameters = []
172-
for constraint in ax_config.outcome_constraints:
173-
analyzed_parameters.append(Parameter(name=constraint.metric.name))
174-
return analyzed_parameters
121+
def _convert_vocs_constraints_to_outcome_constraints(
122+
self,
123+
) -> tuple[List[str], List[Parameter]]:
124+
"""Override to skip conversion since AxClient already has constraints."""
125+
constraint_parameters = []
126+
if hasattr(self._vocs, "constraints") and self._vocs.constraints:
127+
for constraint_name in self._vocs.constraints.keys():
128+
constraint_parameters.append(Parameter(constraint_name))
129+
return [], constraint_parameters
175130

176131
def _create_ax_client(self) -> AxClient:
177132
"""Override the base function to simply return the given."""

optimas/generators/ax/service/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ class AxServiceGenerator(AxGenerator):
8686
8787
"""
8888

89+
returns_id = True
90+
8991
def __init__(
9092
self,
9193
vocs: VOCS,

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ classifiers = [
2323
]
2424
dependencies = [
2525
'libensemble >= 1.3.0',
26+
'numpy < 2.4',
2627
'jinja2',
27-
'pandas',
28+
'pandas < 3',
2829
'mpi4py',
2930
'pydantic >= 2.0',
3031
]

0 commit comments

Comments
 (0)