diff --git a/docs/getting_started.md b/docs/getting_started.md index b4fa8aa46..53e0465fe 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -13,18 +13,17 @@ pip install neural-pipeline-search ## The 3 Main Components -1. **Establish a [`pipeline_space`](reference/pipeline_space.md)**: +1. **Establish a [`pipeline_space=`](reference/neps_spaces.md)**: ```python -pipeline_space={ - "some_parameter": (0.0, 1.0), # float - "another_parameter": (0, 10), # integer - "optimizer": ["sgd", "adam"], # categorical - "epoch": neps.Integer(lower=1, upper=100, is_fidelity=True), - "learning_rate": neps.Float(lower=1e-5, upper=1, log=True), - "alpha": neps.Float(lower=0.1, upper=1.0, prior=0.99, prior_confidence="high") -} - +class ExampleSpace(neps.PipelineSpace): + # Define the parameters of your search space + some_parameter = neps.Float(min_value=0.0, max_value=1.0) # float + another_parameter = neps.Integer(min_value=0, max_value=10) # integer + optimizer = neps.Categorical(choices=("sgd", "adam")) # categorical + epoch = neps.Fidelity(neps.Integer(min_value=1, max_value=100)) + learning_rate = neps.Float(min_value=1e-5, max_value=1, log=True) + alpha = neps.Float(min_value=0.1, max_value=1.0, prior=0.99, prior_confidence="high") ``` 2. **Define an `evaluate_pipeline()` function**: @@ -42,7 +41,7 @@ def evaluate_pipeline(some_parameter: float, 3. **Execute with [`neps.run()`](reference/neps_run.md)**: ```python -neps.run(evaluate_pipeline, pipeline_space) +neps.run(evaluate_pipeline, ExampleSpace()) ``` --- @@ -52,7 +51,7 @@ neps.run(evaluate_pipeline, pipeline_space) The [reference](reference/neps_run.md) section provides detailed information on the individual components of NePS. 1. How to use the [**`neps.run()`** function](reference/neps_run.md) to start the optimization process. -2. The different [search space](reference/pipeline_space.md) options available. +2. The different [search space](reference/neps_spaces.md) options available. 3. How to choose and configure the [optimizer](reference/optimizers.md) used. 4. How to define the [`evaluate_pipeline()` function](reference/evaluate_pipeline.md). 5. How to [analyze](reference/analyse.md) the optimization runs. diff --git a/docs/index.md b/docs/index.md index 569159a4f..63b533a94 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,33 +59,26 @@ import logging # 1. Define a function that accepts hyperparameters and computes the validation error -def evaluate_pipeline( - hyperparameter_a: float, hyperparameter_b: int, architecture_parameter: str -) -> dict: +def evaluate_pipeline(hyperparameter_a: float, hyperparameter_b: int, architecture_parameter: str): # Create your model model = MyModel(architecture_parameter) # Train and evaluate the model with your training pipeline - validation_error = train_and_eval( - model, hyperparameter_a, hyperparameter_b - ) + validation_error = train_and_eval(model, hyperparameter_a, hyperparameter_b) return validation_error # 2. Define a search space of parameters; use the same parameter names as in evaluate_pipeline -pipeline_space = dict( - hyperparameter_a=neps.Float( - lower=0.001, upper=0.1, log=True # The search space is sampled in log space - ), - hyperparameter_b=neps.Integer(lower=1, upper=42), - architecture_parameter=neps.Categorical(["option_a", "option_b"]), -) +class ExampleSpace(neps.PipelineSpace): + hyperparameter_a = neps.Float(min_value=0.001, max_value=0.1, log=True) # Log scale parameter + hyperparameter_b = neps.Integer(min_value=1, max_value=42) + architecture_parameter = neps.Categorical(choices=("option_a", "option_b")) # 3. Run the NePS optimization logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=ExampleSpace(), root_directory="path/to/save/results", # Replace with the actual path. evaluations_to_spend=100, ) diff --git a/docs/reference/evaluate_pipeline.md b/docs/reference/evaluate_pipeline.md index e387124c3..300d22736 100644 --- a/docs/reference/evaluate_pipeline.md +++ b/docs/reference/evaluate_pipeline.md @@ -1,4 +1,4 @@ -# The `evaluate_pipeline` function +# The `evaluate_pipeline` function > **TL;DR** > *Sync*: return a scalar or a dict ⟶ NePS records it automatically. @@ -6,33 +6,33 @@ --- -## 1  Return types +## 1 Return types | Allowed return | When to use | Minimal example | | -------------- | ------------------------------------------- | -------------------------------------------- | | **Scalar** | simple objective, single fidelity | `return loss` | | **Dict** | need cost/extra metrics | `{"objective_to_minimize": loss, "cost": 3}` | -| **`None`** | you launch the job elsewhere (SLURM, k8s …) | *see § 3 Async* | +| **`None`** | you launch the job elsewhere (SLURM, k8s …) | *see § 3 Async* | All other values raise a `TypeError` inside NePS. -## 2  Result dictionary keys +## 2 Result dictionary keys | key | purpose | required? | | ----------------------- | ---------------------------------------------------------------------------- | ----------------------------- | | `objective_to_minimize` | scalar NePS will minimise | **yes** | -| `cost` | wall‑clock, GPU‑hours, … — only if you passed `cost_to_spend` to `neps.run` | yes *iff* cost budget enabled | +| `cost` | wall‑clock, GPU‑hours, … — only if you passed `cost_to_spend` to `neps.run` | yes *iff* cost budget enabled | | `learning_curve` | list/np.array of intermediate objectives | optional | | `extra` | any JSON‑serialisable blob | optional | | `exception` | any Exception illustrating the error in evaluation | optional | -> **Tip**  Return exactly what you need; extra keys are preserved in the trial’s `report.yaml`. +> **Tip** Return exactly what you need; extra keys are preserved in the trial’s `report.yaml`. --- -## 3  Asynchronous evaluation (advanced) +## 3 Asynchronous evaluation (advanced) -### 3.1 Design +### 3.1 Design 1. **The Python side** (your `evaluate_pipeline` function) @@ -52,9 +52,9 @@ All other values raise a `TypeError` inside NePS. when it finishes. This writes `report.yaml` and marks the trial *SUCCESS* / *CRASHED*. -### 3.2 Code walk‑through +### 3.2 Code walk‑through -`submit.py` – called by NePS synchronously +`submit.py` – called by NePS synchronously ```python from pathlib import Path @@ -68,7 +68,7 @@ def evaluate_pipeline( learning_rate: float, optimizer: str, ): - # 1) write a Slurm script + # 1) write a Slurm script script = f"""#!/bin/bash #SBATCH --time=0-00:10 #SBATCH --job-name=trial_{pipeline_id} @@ -83,15 +83,15 @@ python run_pipeline.py \ --root_dir {root_directory} """) - # 2) submit and RETURN None (async) + # 2) submit and RETURN None (async) script_path = pipeline_directory / "submit.sh" script_path.write_text(script) os.system(f"sbatch {script_path}") - return None # ⟵ signals async mode + return None # ⟵ signals async mode ``` -`run_pipeline.py` – executed on the compute node +`run_pipeline.py` – executed on the compute node ```python import argparse, json, time, neps @@ -128,7 +128,7 @@ neps.save_pipeline_results( the default value for `post_run_summary` is True, if you want to prevent any summary creation, you should specify in the arguments. -### 3.3 Why this matters +### 3.3 Why this matters * No worker idles while your job is in the queue ➜ better throughput. * Crashes inside the job still mark the trial *CRASHED* instead of hanging. @@ -138,7 +138,7 @@ the default value for `post_run_summary` is True, if you want to prevent any sum * When using async approach, one worker, may create as many trials as possible, of course that in `Slurm` or other workload managers it's impossible to overload the system because of limitations set for each user, but if you want to control resources used for optimization, it's crucial to set `max_evaluations_per_run` when calling `neps.run`. -## 4  Extra injected arguments +## 4 Extra injected arguments | name | provided when | description | | ----------------------------- | ----------------------- | ---------------------------------------------------------- | @@ -150,7 +150,7 @@ Use them to handle warm‑starts, logging and result persistence. --- -## 5  Checklist +## 5 Checklist * [x] Return scalar **or** dict **or** `None`. * [x] Include `cost` when using cost budgets. diff --git a/docs/reference/neps_run.md b/docs/reference/neps_run.md index 356f27c47..0e985e18c 100644 --- a/docs/reference/neps_run.md +++ b/docs/reference/neps_run.md @@ -17,15 +17,15 @@ import neps def evaluate_pipeline(learning_rate: float, epochs: int) -> float: # Your code here - return loss +class ExamplePipeline(neps.PipelineSpace): + learning_rate = neps.Float(1e-3, 1e-1, log=True) + epochs = neps.Fidelity(neps.Integer(10, 100)) + neps.run( evaluate_pipeline=evaluate_pipeline, # (1)! - pipeline_space={, # (2)! - "learning_rate": neps.Float(1e-3, 1e-1, log=True), - "epochs": neps.Integer(10, 100) - }, + pipeline_space=ExamplePipeline(), # (2)! root_directory="path/to/result_dir" # (3)! ) ``` @@ -33,16 +33,16 @@ neps.run( 1. The objective function, targeted by NePS for minimization, by evaluation various configurations. It requires these configurations as input and should return either a dictionary or a sole loss value as the output. 2. This defines the search space for the configurations from which the optimizer samples. - It accepts either a dictionary with the configuration names as keys, a path to a YAML configuration file, or a [`configSpace.ConfigurationSpace`](https://automl.github.io/ConfigSpace/) object. - For comprehensive information and examples, please refer to the detailed guide available [here](../reference/pipeline_space.md) + It accepts a class instance inheriting from `neps.PipelineSpace` or a [`configSpace.ConfigurationSpace`](https://automl.github.io/ConfigSpace/) object. + For comprehensive information and examples, please refer to the detailed guide available [here](../reference/neps_spaces.md) 3. The directory path where the information about the optimization and its progress gets stored. This is also used to synchronize multiple calls to `neps.run()` for parallelization. See the following for more: -* What kind of [pipeline space](../reference/pipeline_space.md) can you define? -* What goes in and what goes out of [`evaluate_pipeline()`](../reference/evaluate_pipeline.md)? +* What kind of [pipeline space](../reference/neps_spaces.md) can you define? +* What goes in and what goes out of [`evaluate_pipeline()`](../reference/neps_run.md)? ## Budget, how long to run? To define a budget, provide `evaluations_to_spend=` to [`neps.run()`][neps.api.run], @@ -69,7 +69,7 @@ neps.run( 2. Prevents the initiation of new evaluations once this cost threshold is surpassed. This can be any kind of cost metric you like, such as time, energy, or monetary, as long as you can calculate it. This requires adding a cost value to the output of the `evaluate_pipeline` function, for example, return `#!python {'objective_to_minimize': loss, 'cost': cost}`. - For more details, please refer [here](../reference/evaluate_pipeline.md) + For more details, please refer [here](../reference/neps_spaces.md) ## Getting some feedback, logging NePS will not print anything to the console. To view the progress of workers, @@ -140,7 +140,7 @@ provided to [`neps.run()`][neps.api.run]. │ └── config_2 │ ├── config.yaml │ └── metadata.json - ├── summary + ├── summary │ ├── full.csv │ └── short.csv │ ├── best_config_trajectory.txt diff --git a/docs/reference/neps_spaces.md b/docs/reference/neps_spaces.md new file mode 100644 index 000000000..5b0f2cf31 --- /dev/null +++ b/docs/reference/neps_spaces.md @@ -0,0 +1,254 @@ +# NePS Spaces + +**NePS Spaces** provide a powerful framework for defining and optimizing complex search spaces across the entire pipeline, including [hyperparameters](#1-constructing-hyperparameter-spaces), [architecture search](#3-constructing-architecture-spaces) and [more](#4-constructing-complex-spaces). + +## 1. Constructing Hyperparameter Spaces + +**NePS spaces** include all the necessary components to define a Hyperparameter Optimization (HPO) search space like: + +- [`neps.Integer`][neps.space.neps_spaces.parameters.Integer]: Discrete integer values +- [`neps.Float`][neps.space.neps_spaces.parameters.Float]: Continuous float values +- [`neps.Categorical`][neps.space.neps_spaces.parameters.Categorical]: Discrete categorical values +- [`neps.Fidelity`][neps.space.neps_spaces.parameters.Fidelity]: Special type for float or integer, [multi-fidelity](../reference/search_algorithms/multifidelity.md) parameters (e.g., epochs, dataset size) + +Using these types, you can define the parameters that NePS will optimize during the search process. +A **NePS space** is defined as a subclass of [`PipelineSpace`][neps.space.neps_spaces.parameters.PipelineSpace]. Here we define the hyperparameters that make up the space, like so: + +```python +import neps + +class MySpace(neps.PipelineSpace): + float_param = neps.Float(min_value=0.1, max_value=1.0) + int_param = neps.Integer(min_value=1, max_value=10) + cat_param = neps.Categorical(choices=("A", "B", "C")) +``` + +!!! info "Using **NePS Spaces**" + + To search a **NePS space**, pass it as the `pipeline_space` argument to the `neps.run()` function: + + ```python + neps.run( + ..., + pipeline_space=MySpace() + ) + ``` + + For more details on how to use the `neps.run()` function, see the [NePS Run Reference](../reference/neps_run.md). + +### Using cheap approximation, providing a [**Fidelity**](../reference/search_algorithms/landing_page_algo.md#what-is-multi-fidelity-optimization) Parameter + +Passing a [`neps.Integer`][neps.space.neps_spaces.parameters.Integer] or [`neps.Float`][neps.space.neps_spaces.parameters.Float] to a [`neps.Fidelity`][neps.space.neps_spaces.parameters.Fidelity] allows you to employ multi-fidelity optimization strategies, which can significantly speed up the optimization process by evaluating configurations at different fidelities (e.g., training for fewer epochs): + +```python +epochs = neps.Fidelity(neps.Integer(1, 16)) +``` + +For more details on how to use fidelity parameters, see the [Multi-Fidelity](../reference/search_algorithms/landing_page_algo.md#what-is-multi-fidelity-optimization) section. + +### Using your knowledge, providing a [**Prior**](../reference/search_algorithms/landing_page_algo.md#what-are-priors) + +You can provide **your knowledge about where a good value for this parameter lies** by indicating a `prior=`. You can also specify a `prior_confidence=` to indicate how strongly you want NePS to focus on these, one of either `"low"`, `"medium"`, or `"high"`: + +```python +# Here "A" is used as a prior, indicated by its index 0 +cat_with_prior = neps.Categorical(choices=("A", "B", "C"), prior=0, prior_confidence="high") +``` + +For more details on how to use priors, see the [Priors](../reference/search_algorithms/landing_page_algo.md#what-are-priors) section. + +!!! info "Adding and removing parameters from **NePS Spaces**" + + To add or remove parameters from a `PipelineSpace` after its definition, you can use the `+` operator or the `add()` and `remove()` methods. Mind you, these methods do NOT modify the existing space in-place, but return a new instance with the modifications: + + ```python + space = MySpace() + # Adding a new parameter, this will appear as param_n where n is the next available index + space = space + neps.Float(min_value=0.01, max_value=0.1) + # Or using the add() method, this allows you to specify a name + space = space.add(neps.Integer(min_value=5, max_value=15), name="new_int_param") + # Removing a parameter by its name + space = space.remove("cat_param") + ``` + +## 3. Constructing Architecture Spaces + +Additionally, **NePS spaces** can describe **complex (hierarchical) architectures** using: + +- [`Operation`][neps.space.neps_spaces.parameters.Operation]: Define operations and their arguments + +Operations can be Callables, (e.g. pytorch objects) which will be passed to the evaluation function as such: + +```python + +import torch.nn + +class NNSpace(PipelineSpace): + + # Defining operations for different activation functions + _relu = Operation(operator=torch.nn.ReLU) + _sigmoid = Operation(operator=torch.nn.Sigmoid) + + # We can then search over these operations and use them in the evaluation function + activation_function = neps.Categorical(choices=(_relu, _sigmoid)) +``` + +!!! info "Intermediate parameters" + + When defining parameters that should not be passed to the evaluation function and instead are used in other parameters, prefix them with an underscore, like here in `_layer_size`. Otherwise this might lead to `unexpected arguments` errors. + +Operation also allow for (keyword-)arguments to be defined, including other parameters of the space: + +```python + + batch_size = neps.Categorical(choices=(16, 32, 64)) + + _layer_size = neps.Integer(min_value=80, max_value=100) + + hidden_layer = neps.Operation( + operator=torch.nn.Linear, + kwargs={"input_size": 64, # Fixed input size + "output_size": _layer_size}, # Using the previously defined parameter + + # Or for non-keyword arguments: + args=(activation_function,) + ) +``` + +This can be used for efficient architecture search by defining cells and blocks of operations, that make up a neural network. +The `evaluate_pipeline` function will receive the sampled operations as Callables, which can be used to instantiate the model: + +```python +def evaluate_pipeline( + activation_function: torch.nn.Module, + batch_size: int, + hidden_layer: torch.nn.Linear): + + # Instantiate the model using the sampled operations + model = torch.nn.Sequential( + torch.nn.Flatten(), + hidden_layer, + activation_function, + torch.nn.Linear(in_features=hidden_layer.out_features, out_features=10) + ) + + # Use the model for training and return the validation accuracy + model.train(batch_size=batch_size, ...) + return model.evaluate(...).accuracy + +``` + +??? abstract "Structural Space-compatible optimizers" + + Currently, NePS Spaces is compatible with these optimizers, which can be imported from [neps.algorithms][neps.optimizers.algorithms--neps-algorithms]: + + - [`Random Search`][neps.optimizers.algorithms.random_search], which can sample the space uniformly at random + - [`Complex Random Search`][neps.optimizers.algorithms.complex_random_search], which can sample the space uniformly at random, using priors and mutating previously sampled configurations + - [`PriorBand`][neps.optimizers.algorithms.priorband], which uses [multi-fidelity](./search_algorithms/multifidelity.md) and the prior knowledge encoded in the NePS space + +## 4. Constructing Complex Spaces + +Until now all parameters are sampled once and their value used for all occurrences. This section describes how to resample parameters in different contexts using: + +- [`neps.Resampled`][neps.space.neps_spaces.parameters.Resampled]: Resample from an existing parameters range + +With `neps.Resampled` you can reuse a parameter, even themselves recursively, but with a new value each time: + +```python +class ResampledSpace(neps.PipelineSpace): + float_param = neps.Float(min_value=0, max_value=1) + + # The resampled parameter will have the same range but will be sampled + # independently, so it can take a different value than its source + resampled_float = neps.Resampled(source=float_param) +``` + +This is especially useful for defining complex architectures, where e.g. a cell block is defined and then resampled multiple times to create a neural network architecture: + +```python +class CNN_Space(neps.PipelineSpace): + _kernel_size = neps.Integer(min_value=5, max_value=8) + + # Define a cell block that can be resampled + # It will resample a new kernel size from _kernel_size each time + # Each instance will be identically but independently sampled + _cell_block = neps.Operation( + operator=torch.nn.Conv2d, + kwargs={"kernel_size": neps.Resampled(source=_kernel_size)} + ) + + # Resample the cell block multiple times to create a convolutional neural network + cnn = torch.nn.Sequential( + neps.Resampled(_cell_block), + neps.Resampled(_cell_block), + neps.Resampled(_cell_block), + ) + +def evaluate_pipeline(cnn: torch.nn.Module): + # Use the cnn model for training and return the validation accuracy + cnn.train(...) + return cnn.evaluate(...).accuracy +``` + +??? info "Self- and future references" + + When referencing itself or a not yet defined parameter (to enable recursions) use a string of that parameters name: + + ```python + self_reference = Categorical( + choices=( + # It will either choose to resample itself twice + (Resampled("self_reference"), Resampled("self_reference")), + # Or it will sample the future parameter + (Resampled("future_param"),), + ) + ) + # This results in a (possibly infinite) tuple of independently sampled future_params + + future_param = Float(min_value=0, max_value=5) + ``` + +!!! tip "Complex structural spaces" + + Together, [Resampling][neps.space.neps_spaces.parameters.Resampled] and [operations][neps.space.neps_spaces.parameters.Operation] allow you to define complex search spaces across the whole ML-pipeline akin to [Context-Free Grammars (CFGs)](https://en.wikipedia.org/wiki/Context-free_grammar), exceeding architecture search. For example, you can sample neural optimizers from a set of instructions, as done in [`NOSBench`](https://openreview.net/pdf?id=5Lm2ghxMlp) to train models. + +## Inspecting Configurations + +NePS saves the configurations as paths, where each sampling decision is recorded. As they are hard to read, so you can load the configuration from the `results/.../configs` directory using the [`NepsCompatConverter`][neps.space.neps_spaces.neps_space.NepsCompatConverter] class, which converts the configuration such that it can be used with the NePS Spaces API: + +```python +from neps.space.neps_spaces import neps_space +import yaml + +with open("Path/to/config.yaml", "r") as f: + conf_dict = yaml.safe_load(f) +config = NepsCompatConverter.from_neps_config(conf_dict) + +# Use the resolution context to sample the configuration using a +# Sampler that follows the instructions in the configuration +resolved_pipeline, resolution_context = neps_space.resolve(pipeline=NN_Space(), + # Predefined samplings are the decisions made at each sampling step + domain_sampler=neps_space.OnlyPredefinedValuesSampler(predefined_samplings=config.predefined_samplings), + # Environment values are the fidelities and any arguments of the evaluation function not part of the search space + environment_values=config.environment_values) + +# The resolved_pipeline now contains all the parameters and their values, e.g. the Callable model +model_callable = neps_space.convert_operation_to_callable(operation=resolved_pipeline.model) +``` + +## Using ConfigSpace + +For users familiar with the [`ConfigSpace`](https://automl.github.io/ConfigSpace/main/) library, +can also define the `pipeline_space` through `ConfigurationSpace()` + +```python +from configspace import ConfigurationSpace, Float + +configspace = ConfigurationSpace( + { + "learning_rate": Float("learning_rate", bounds=(1e-4, 1e-1), log=True) + "optimizer": ["adam", "sgd", "rmsprop"], + "dropout_rate": 0.5, + } +) +``` diff --git a/docs/reference/optimizers.md b/docs/reference/optimizers.md index 6edef593a..a4159c42a 100644 --- a/docs/reference/optimizers.md +++ b/docs/reference/optimizers.md @@ -42,18 +42,19 @@ NePS provides a multitude of optimizers from the literature, the [algorithms](.. ✅ = supported/necessary, ❌ = not supported, ✔️* = optional, click for details, ✖️\* ignorable, click for details -| Algorithm | [Multi-Fidelity](../reference/search_algorithms/multifidelity.md) | [Priors](../reference/search_algorithms/prior.md) | Model-based | -| :- | :------------: | :----: | :---------: | -| `Grid Search`|[️️✖️*][neps.optimizers.algorithms.grid_search]|❌|❌| -| `Random Search`|[️️✖️*][neps.optimizers.algorithms.random_search]|[✔️*][neps.optimizers.algorithms.random_search]|❌| -| [`Bayesian Optimization`](../reference/search_algorithms/bayesian_optimization.md)|[️️✖️*][neps.optimizers.algorithms.bayesian_optimization]|❌|✅| -| [`Successive Halving`](../reference/search_algorithms/multifidelity.md#1-successive-halfing)|✅|[✔️*][neps.optimizers.algorithms.successive_halving]|❌| -| [`ASHA`](../reference/search_algorithms/multifidelity.md#asynchronous-successive-halving)|✅|[✔️*][neps.optimizers.algorithms.asha]|❌| -| [`Hyperband`](../reference/search_algorithms/multifidelity.md#2-hyperband)|✅|[✔️*][neps.optimizers.algorithms.hyperband]|❌| -| [`Asynch HB`](../reference/search_algorithms/multifidelity.md)|✅|[✔️*][neps.optimizers.algorithms.async_hb]|❌| -| [`IfBO`](../reference/search_algorithms/multifidelity.md#3-in-context-freeze-thaw-bayesian-optimization)|✅|[✔️*][neps.optimizers.algorithms.ifbo]|✅| -| [`PiBO`](../reference/search_algorithms/prior.md#1-pibo)|[️️✖️*][neps.optimizers.algorithms.pibo]|✅|✅| -| [`PriorBand`](../reference/search_algorithms/multifidelity_prior.md#1-priorband)|✅|✅|✅| +| Algorithm | [Multi-Fidelity](../reference/search_algorithms/multifidelity.md) | [Priors](../reference/search_algorithms/prior.md) | Model-based | [NePS-ready](../reference/neps_spaces.md#3-constructing-architecture-spaces) | +| :- | :------------: | :----: | :---------: | :-----------------: | +| `Grid Search`|[️️✖️*][neps.optimizers.algorithms.grid_search]|❌|❌|❌| +| `Random Search`|[️️✖️*][neps.optimizers.algorithms.random_search]|[✔️*][neps.optimizers.algorithms.random_search]|❌|✅| +| `Complex Random Search`|[️️✖️*][neps.optimizers.algorithms.complex_random_search]|[✔️*][neps.optimizers.algorithms.complex_random_search]|❌|✅| +| [`Bayesian Optimization`](../reference/search_algorithms/bayesian_optimization.md)|[️️✖️*][neps.optimizers.algorithms.bayesian_optimization]|❌|✅|❌| +| [`Successive Halving`](../reference/search_algorithms/multifidelity.md#1-successive-halfing)|✅|[✔️*][neps.optimizers.algorithms.successive_halving]|❌|❌| +| [`ASHA`](../reference/search_algorithms/multifidelity.md#asynchronous-successive-halving)|✅|[✔️*][neps.optimizers.algorithms.asha]|❌|❌| +| [`Hyperband`](../reference/search_algorithms/multifidelity.md#2-hyperband)|✅|[✔️*][neps.optimizers.algorithms.hyperband]|❌|❌| +| [`Asynch HB`](../reference/search_algorithms/multifidelity.md)|✅|[✔️*][neps.optimizers.algorithms.async_hb]|❌|❌| +| [`IfBO`](../reference/search_algorithms/multifidelity.md#3-in-context-freeze-thaw-bayesian-optimization)|✅|[✔️*][neps.optimizers.algorithms.ifbo]|✅|❌| +| [`PiBO`](../reference/search_algorithms/prior.md#1-pibo)|[️️✖️*][neps.optimizers.algorithms.pibo]|✅|✅|❌| +| [`PriorBand`](../reference/search_algorithms/multifidelity_prior.md#1-priorband)|✅|✅|✅|✅| If you prefer not to specify a particular optimizer for your AutoML task, you can simply pass `"auto"` or `None` for the neps optimizer. This provides a hassle-free way to get started quickly, as NePS will automatically choose the best optimizer based on the characteristics of your search diff --git a/docs/reference/pipeline_space.md b/docs/reference/pipeline_space.md deleted file mode 100644 index 9844e42a3..000000000 --- a/docs/reference/pipeline_space.md +++ /dev/null @@ -1,108 +0,0 @@ -# Initializing the Pipeline Space - -In NePS, we need to define a `pipeline_space`. -This space can be structured through various approaches, including a Python dictionary, or ConfigSpace. -Each of these methods allows you to specify a set of parameter types, ranging from Float and Categorical to specialized architecture parameters. -Whether you choose a dictionary, or ConfigSpace, your selected method serves as a container or framework -within which these parameters are defined and organized. This section not only guides you through the process of -setting up your `pipeline_space` using these methods but also provides detailed instructions and examples on how to -effectively incorporate various parameter types, ensuring that NePS can utilize them in the optimization process. - - -## Parameters -NePS currently features 4 primary hyperparameter types: - -* [`Categorical`][neps.space.Categorical] -* [`Float`][neps.space.Float] -* [`Integer`][neps.space.Integer] -* [`Constant`][neps.space.Constant] - -Using these types, you can define the parameters that NePS will optimize during the search process. -The most basic way to pass these parameters is through a Python dictionary, where each key-value -pair represents a parameter name and its respective type. -For example, the following Python dictionary defines a `pipeline_space` with four parameters -for optimizing a deep learning model: - -```python -pipeline_space = { - "learning_rate": neps.Float(0.00001, 0.1, log=True), - "num_epochs": neps.Integer(3, 30, is_fidelity=True), - "optimizer": ["adam", "sgd", "rmsprop"], # Categorical - "dropout_rate": 0.5, # Constant -} - -neps.run(.., pipeline_space=pipeline_space) -``` - -??? example "Quick Parameter Reference" - - === "`Categorical`" - - ::: neps.space.Categorical - - === "`Float`" - - ::: neps.space.Float - - === "`Integer`" - - ::: neps.space.Integer - - === "`Constant`" - - ::: neps.space.Constant - - -## Using your knowledge, providing a Prior -When optimizing, you can provide your own knowledge using the parameter `prior=`. -By indicating a `prior=` we take this to be your user prior, -**your knowledge about where a good value for this parameter lies**. - -You can also specify a `prior_confidence=` to indicate how strongly you want NePS, -to focus on these, one of either `"low"`, `"medium"`, or `"high"`. - -```python -import neps - -neps.run( - ..., - pipeline_space={ - "learning_rate": neps.Float(1e-4, 1e-1, log=True, prior=1e-2, prior_confidence="medium"), - "num_epochs": neps.Integer(3, 30, is_fidelity=True), - "optimizer": neps.Categorical(["adam", "sgd", "rmsprop"], prior="adam", prior_confidence="low"), - "dropout_rate": neps.Constant(0.5), - } -) -``` - -!!! warning "Interaction with `is_fidelity`" - - If you specify `is_fidelity=True` and `prior=` for one parameter, this will raise an error. - -Currently the two major algorithms that exploit this in NePS are `PriorBand` -(prior-based `HyperBand`) and `PiBO`, a version of Bayesian Optimization which uses Priors. For more information on priors and algorithms using them, please refer to the [prior documentation](../reference/search_algorithms/prior.md). - -## Using ConfigSpace - -For users familiar with the [`ConfigSpace`](https://automl.github.io/ConfigSpace/main/) library, -can also define the `pipeline_space` through `ConfigurationSpace()` - -```python -from configspace import ConfigurationSpace, Float - -configspace = ConfigurationSpace( - { - "learning_rate": Float("learning_rate", bounds=(1e-4, 1e-1), log=True) - "optimizer": ["adam", "sgd", "rmsprop"], - "dropout_rate": 0.5, - } -) -``` - -!!! warning - - Parameters you wish to use as a **fidelity** are not support through ConfigSpace - at this time. - -For additional information on ConfigSpace and its features, please visit the following -[link](https://github.com/automl/ConfigSpace). diff --git a/docs/reference/search_algorithms/landing_page_algo.md b/docs/reference/search_algorithms/landing_page_algo.md index 7f7be891e..5d818d519 100644 --- a/docs/reference/search_algorithms/landing_page_algo.md +++ b/docs/reference/search_algorithms/landing_page_algo.md @@ -6,18 +6,19 @@ We distinguish between algorithms that use different types of information and st ✅ = supported/necessary, ❌ = not supported, ✔️* = optional, click for details, ✖️\* ignorable, click for details -| Algorithm | [Multi-Fidelity](../search_algorithms/multifidelity.md) | [Priors](../search_algorithms/prior.md) | Model-based | -| :- | :------------: | :----: | :---------: | -| `Grid Search`|[️️✖️*][neps.optimizers.algorithms.grid_search]|❌|❌| -| `Random Search`|[️️✖️*][neps.optimizers.algorithms.random_search]|[✔️*][neps.optimizers.algorithms.random_search]|❌| -| [`Bayesian Optimization`](../search_algorithms/bayesian_optimization.md)|[️️✖️*][neps.optimizers.algorithms.bayesian_optimization]|❌|✅| -| [`Successive Halving`](../search_algorithms/multifidelity.md#1-successive-halfing)|✅|[✔️*][neps.optimizers.algorithms.successive_halving]|❌| -| [`ASHA`](../search_algorithms/multifidelity.md#asynchronous-successive-halving)|✅|[✔️*][neps.optimizers.algorithms.asha]|❌| -| [`Hyperband`](../search_algorithms/multifidelity.md#2-hyperband)|✅|[✔️*][neps.optimizers.algorithms.hyperband]|❌| -| [`Asynch HB`](../search_algorithms/multifidelity.md)|✅|[✔️*][neps.optimizers.algorithms.async_hb]|❌| -| [`IfBO`](../search_algorithms/multifidelity.md#3-in-context-freeze-thaw-bayesian-optimization)|✅|[✔️*][neps.optimizers.algorithms.ifbo]|✅| -| [`PiBO`](../search_algorithms/prior.md#1-pibo)|[️️✖️*][neps.optimizers.algorithms.pibo]|✅|✅| -| [`PriorBand`](../search_algorithms/multifidelity_prior.md#1-priorband)|✅|✅|✅| +| Algorithm | [Multi-Fidelity](../search_algorithms/multifidelity.md) | [Priors](../search_algorithms/prior.md) | Model-based | [NePS-ready](../neps_spaces.md#3-constructing-architecture-spaces) | +| :- | :------------: | :----: | :---------: | :-----------------: | +| `Grid Search`|[️️✖️*][neps.optimizers.algorithms.grid_search]|❌|❌|❌| +| `Random Search`|[️️✖️*][neps.optimizers.algorithms.random_search]|[✔️*][neps.optimizers.algorithms.random_search]|❌|✅| +| `Complex Random Search`|[️️✖️*][neps.optimizers.algorithms.complex_random_search]|[✔️*][neps.optimizers.algorithms.complex_random_search]|❌|✅| +| [`Bayesian Optimization`](../search_algorithms/bayesian_optimization.md)|[️️✖️*][neps.optimizers.algorithms.bayesian_optimization]|❌|✅|❌| +| [`Successive Halving`](../search_algorithms/multifidelity.md#1-successive-halfing)|✅|[✔️*][neps.optimizers.algorithms.successive_halving]|❌|❌| +| [`ASHA`](../search_algorithms/multifidelity.md#asynchronous-successive-halving)|✅|[✔️*][neps.optimizers.algorithms.asha]|❌|❌| +| [`Hyperband`](../search_algorithms/multifidelity.md#2-hyperband)|✅|[✔️*][neps.optimizers.algorithms.hyperband]|❌|❌| +| [`Asynch HB`](../search_algorithms/multifidelity.md)|✅|[✔️*][neps.optimizers.algorithms.async_hb]|❌|❌| +| [`IfBO`](../search_algorithms/multifidelity.md#3-in-context-freeze-thaw-bayesian-optimization)|✅|[✔️*][neps.optimizers.algorithms.ifbo]|✅|❌| +| [`PiBO`](../search_algorithms/prior.md#1-pibo)|[️️✖️*][neps.optimizers.algorithms.pibo]|✅|✅|❌| +| [`PriorBand`](../search_algorithms/multifidelity_prior.md#1-priorband)|✅|✅|✅|✅| ## What is Multi-Fidelity Optimization? @@ -36,7 +37,7 @@ We present a collection of MF-algorithms [here](./multifidelity.md) and algorith ## What are Priors? -Priors are used when there exists some information about the search space, that can be used to guide the optimization process. This information could come from expert domain knowledge or previous experiments. A Prior is provided in the form of a distribution over one dimension of the search space, with a `mean` (the suspected optimum) and a `confidence level`, or `variance`. We discuss how Priors can be included in your NePS-search space [here](../../reference/pipeline_space.md#using-your-knowledge-providing-a-prior). +Priors are used when there exists some information about the search space, that can be used to guide the optimization process. This information could come from expert domain knowledge or previous experiments. A Prior is provided in the form of a distribution over one dimension of the search space, with a `mean` (the suspected optimum) and a `confidence level`, or `variance`. We discuss how Priors can be included in your NePS-search space [here](../../reference/neps_spaces.md#1-constructing-hyperparameter-spaces). !!! tip "Advantages of using Priors" diff --git a/docs/reference/search_algorithms/multifidelity.md b/docs/reference/search_algorithms/multifidelity.md index 171b1cc41..c9accfb01 100644 --- a/docs/reference/search_algorithms/multifidelity.md +++ b/docs/reference/search_algorithms/multifidelity.md @@ -108,7 +108,7 @@ See the algorithm's implementation details in the [api][neps.optimizers.algorith ??? example "Practical Tips" - - ``IfBO`` is a good choice when the problem allows for low-fidelity configurations to be continued to retrieve high-fidelity results, utilizing neps's [checkpointing](../evaluate_pipeline.md#arguments-for-convenience) feature. + - ``IfBO`` is a good choice when the problem allows for low-fidelity configurations to be continued to retrieve high-fidelity results, utilizing neps's [checkpointing](../evaluate_pipeline.md#4-extra-injected-arguments) feature. ___ For optimizers using both Priors and Multi-Fidelity, please refer [here](multifidelity_prior.md). diff --git a/mkdocs.yml b/mkdocs.yml index f10fe23b8..1218edc6a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -139,7 +139,7 @@ nav: - Getting Started: 'getting_started.md' - Reference: - Run: 'reference/neps_run.md' - - Search Space: 'reference/pipeline_space.md' + - NePS Spaces: 'reference/neps_spaces.md' - The Evaluate Function: 'reference/evaluate_pipeline.md' - Analysing Runs: 'reference/analyse.md' - Optimizer: 'reference/optimizers.md' diff --git a/neps/__init__.py b/neps/__init__.py index af54987b3..71c12258e 100644 --- a/neps/__init__.py +++ b/neps/__init__.py @@ -1,10 +1,27 @@ +"""NePS: A framework for Neural Architecture Search and Hyperparameter Optimization. +This module provides a unified interface for defining search spaces, running optimizers, +and visualizing results. It includes various optimizers, search space definitions, +and plotting utilities, making it easy to experiment with different configurations +and algorithms. +""" + from neps.api import import_trials, run, save_pipeline_results from neps.optimizers import algorithms from neps.optimizers.ask_and_tell import AskAndTell from neps.optimizers.optimizer import SampledConfig from neps.plot.plot import plot from neps.plot.tensorboard_eval import tblogger -from neps.space import Categorical, Constant, Float, Integer, SearchSpace +from neps.space import HPOCategorical, HPOConstant, HPOFloat, HPOInteger, SearchSpace +from neps.space.neps_spaces.parameters import ( + Categorical, + ConfidenceLevel, + Fidelity, + Float, + Integer, + Operation, + PipelineSpace, + Resampled, +) from neps.state import BudgetInfo, Trial from neps.state.pipeline_eval import UserResultDict from neps.status.status import status @@ -14,9 +31,17 @@ "AskAndTell", "BudgetInfo", "Categorical", - "Constant", + "ConfidenceLevel", + "Fidelity", "Float", + "HPOCategorical", + "HPOConstant", + "HPOFloat", + "HPOInteger", "Integer", + "Operation", + "PipelineSpace", + "Resampled", "SampledConfig", "SearchSpace", "Trial", diff --git a/neps/api.py b/neps/api.py index 10ca2702f..b7155c3b9 100644 --- a/neps/api.py +++ b/neps/api.py @@ -11,9 +11,16 @@ from neps.normalization import _normalize_imported_config from neps.optimizers import AskFunction, OptimizerChoice, load_optimizer from neps.runtime import _launch_runtime, _save_results +from neps.space.neps_spaces.neps_space import ( + adjust_evaluation_pipeline_for_neps_space, + check_neps_space_compatibility, + convert_classic_to_neps_search_space, + convert_neps_to_classic_search_space, +) +from neps.space.neps_spaces.parameters import PipelineSpace from neps.space.parsing import convert_to_space from neps.state import NePSState, OptimizationState, SeedSnapshot -from neps.status.status import post_run_csv, trajectory_of_improvements +from neps.status.status import post_run_csv from neps.utils.common import dynamic_load_object from neps.validation import _validate_imported_config, _validate_imported_result @@ -21,20 +28,15 @@ from ConfigSpace import ConfigurationSpace from neps.optimizers.algorithms import CustomOptimizer - from neps.space import Parameter, SearchSpace - from neps.state import EvaluatePipelineReturn - from neps.state.pipeline_eval import UserResultDict + from neps.space import SearchSpace + from neps.state.pipeline_eval import EvaluatePipelineReturn, UserResultDict logger = logging.getLogger(__name__) -def run( # noqa: C901, D417, PLR0913 +def run( # noqa: C901, D417, PLR0913, PLR0912, PLR0915 evaluate_pipeline: Callable[..., EvaluatePipelineReturn] | str, - pipeline_space: ( - Mapping[str, dict | str | int | float | Parameter] - | SearchSpace - | ConfigurationSpace - ), + pipeline_space: ConfigurationSpace | PipelineSpace, *, root_directory: str | Path = "neps_results", overwrite_root_directory: bool = False, @@ -55,7 +57,9 @@ def run( # noqa: C901, D417, PLR0913 OptimizerChoice | Mapping[str, Any] | tuple[OptimizerChoice, Mapping[str, Any]] - | Callable[Concatenate[SearchSpace, ...], AskFunction] + | Callable[Concatenate[SearchSpace, ...], AskFunction] # Hack, while we transit + | Callable[Concatenate[PipelineSpace, ...], AskFunction] # from SearchSpace to + | Callable[Concatenate[SearchSpace | PipelineSpace, ...], AskFunction] # Pipeline | CustomOptimizer | Literal["auto"] ) = "auto", @@ -79,30 +83,29 @@ def evaluate_pipeline(some_parameter: float) -> float: validation_error = -some_parameter return validation_error - pipeline_space = dict(some_parameter=neps.Float(lower=0, upper=1)) + class MySpace(PipelineSpace): + dataset = "mnist" # constant + nlayers = neps.Integer(2,10) # integer + alpha = neps.Float(0.1, 1.0) # float + optimizer = neps.Categorical( # categorical + ("adam", "sgd", "rmsprop") + ) + learning_rate = neps.Float( # log spaced float + min_value=1e-5, max_value=1, log=True + ) + epochs = neps.Fidelity( # fidelity integer + neps.Integer(1, 100) + ) + batch_size = neps.Integer( # integer with a prior + min_value=32, + max_value=512, + prior=128, + prior_confidence="medium" + ) + neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space={ - "some_parameter": (0.0, 1.0), # float - "another_parameter": (0, 10), # integer - "optimizer": ["sgd", "adam"], # categorical - "epoch": neps.Integer( # fidelity integer - lower=1, - upper=100, - is_fidelity=True - ), - "learning_rate": neps.Float( # log spaced float - lower=1e-5, - upper=1, - log=True - ), - "alpha": neps.Float( # float with a prior - lower=0.1, - upper=1.0, - prior=0.99, - prior_confidence="high", - ) - }, + pipeline_space=MySpace(), root_directory="usage_example", evaluations_to_spend=5, max_evaluations_per_run=10, @@ -137,25 +140,28 @@ def evaluate_pipeline(some_parameter: float) -> float: This most direct way to specify the search space is as follows: ```python - neps.run( - pipeline_space={ - "dataset": "mnist", # constant - "nlayers": (2, 10), # integer - "alpha": (0.1, 1.0), # float - "optimizer": [ # categorical - "adam", "sgd", "rmsprop" - ], - "learning_rate": neps.Float(, # log spaced float - lower=1e-5, upper=1, log=True - ), - "epochs": neps.Integer( # fidelity integer - lower=1, upper=100, is_fidelity=True - ), - "batch_size": neps.Integer( # integer with a prior - lower=32, upper=512, prior=128 - ), + MySpace(PipelineSpace): + dataset = "mnist" # constant + nlayers = neps.Integer(2,10) # integer + alpha = neps.Float(0.1, 1.0) # float + optimizer = neps.Categorical( # categorical + ("adam", "sgd", "rmsprop") + ) + learning_rate = neps.Float( # log spaced float + min_value=1e-5, max_value=1, log=True + ) + epochs = neps.Fidelity( # fidelity integer + neps.Integer(1, 100) + ) + batch_size = neps.Integer( # integer with a prior + min_value=32, + max_value=512, + prior=128, + prior_confidence="medium" + ) - } + neps.run( + pipeline_space=MySpace() ) ``` @@ -167,29 +173,8 @@ def evaluate_pipeline(some_parameter: float) -> float: * `prior=`: If you have a good idea about what a good setting for a parameter may be, you can set this as the prior for - a parameter. You can specify this along with `prior_confidence` - if you would like to assign a `"low"`, `"medium"`, or `"high"` - confidence to the prior. - - - !!! note "Yaml support" - - To support spaces defined in yaml, you may also define the parameters - as dictionarys, e.g., - - ```python - neps.run( - pipeline_space={ - "dataset": "mnist", - "nlayers": {"type": "int", "lower": 2, "upper": 10}, - "alpha": {"type": "float", "lower": 0.1, "upper": 1.0}, - "optimizer": {"type": "cat", "choices": ["adam", "sgd", "rmsprop"]}, - "learning_rate": {"type": "float", "lower": 1e-5, "upper": 1, "log": True}, - "epochs": {"type": "int", "lower": 1, "upper": 100, "is_fidelity": True}, - "batch_size": {"type": "int", "lower": 32, "upper": 512, "prior": 128}, - } - ) - ``` + a parameter. You specify this along with `prior_confidence` + to assign a `"low"`, `"medium"`, or `"high"`confidence to the prior. !!! note "ConfigSpace support" @@ -205,10 +190,11 @@ def evaluate_pipeline(some_parameter: float) -> float: holding summary information about the configs and results. max_evaluations_per_run: Number of evaluations this specific call should do. - ??? note "Limitation on Async mode" - Currently, there is no specific number to control number of parallel evaluations running with - the same worker, so in case you want to limit the number of parallel evaluations, - it's crucial to limit the number of evaluations per run. + + ??? note "Limitation on Async mode" + Currently, there is no specific number to control number of parallel evaluations running with + the same worker, so in case you want to limit the number of parallel evaluations, + it's crucial to limit the number of evaluations per run. evaluations_to_spend: Number of evaluations after which to terminate. This is shared between all workers operating in the same `root_directory`. @@ -282,98 +268,7 @@ def evaluate_pipeline(some_parameter: float) -> float: ??? note "Available optimizers" - --- - - * `#!python "bayesian_optimization"`, - - ::: neps.optimizers.algorithms.bayesian_optimization - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - - * `#!python "ifbo"` - - ::: neps.optimizers.algorithms.ifbo - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - - * `#!python "successive_halving"`: - - ::: neps.optimizers.algorithms.successive_halving - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - - * `#!python "hyperband"`: - - ::: neps.optimizers.algorithms.hyperband - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - - * `#!python "priorband"`: - - ::: neps.optimizers.algorithms.priorband - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - - * `#!python "asha"`: - - ::: neps.optimizers.algorithms.asha - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - - * `#!python "async_hb"`: - - ::: neps.optimizers.algorithms.async_hb - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - - * `#!python "random_search"`: - - ::: neps.optimizers.algorithms.random_search - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - - * `#!python "grid_search"`: - - ::: neps.optimizers.algorithms.grid_search - options: - show_root_heading: false - show_signature: false - show_source: false - - --- - + See the [optimizers documentation](../../reference/search_algorithms/landing_page_algo.md) for a list of available optimizers. With any optimizer choice, you also may provide some additional parameters to the optimizers. We do not recommend this unless you are familiar with the optimizer you are using. You @@ -383,10 +278,11 @@ def evaluate_pipeline(some_parameter: float) -> float: ```python neps.run( ..., - optimzier={ - "name": "priorband", - "sample_prior_first": True, - } + optimzier=("priorband", + { + "sample_prior_first": True, + } + ) ) ``` @@ -478,7 +374,43 @@ def __call__( ) logger.info(f"Starting neps.run using root directory {root_directory}") + + # Check if the pipeline_space only contains basic HPO parameters. + # If yes, we convert it to a classic SearchSpace, to use with the old optimizers. + # If no, we use adjust_evaluation_pipeline_for_neps_space to convert the + # pipeline_space and only use the new NEPS optimizers. + + # If the optimizer is not a NEPS algorithm, we try to convert the pipeline_space + + neps_classic_space_compatibility = check_neps_space_compatibility(optimizer) + if neps_classic_space_compatibility in ["both", "classic"] and isinstance( + pipeline_space, PipelineSpace + ): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space: + pipeline_space = converted_space space = convert_to_space(pipeline_space) + + if neps_classic_space_compatibility == "neps" and not isinstance( + space, PipelineSpace + ): + space = convert_classic_to_neps_search_space(space) + + # Optimizer check, if the search space is a Pipeline and the optimizer is not a NEPS + # algorithm, we raise an error, as the optimizer is not compatible. + if isinstance(space, PipelineSpace) and neps_classic_space_compatibility == "classic": + raise ValueError( + "The provided optimizer is not compatible with this complex search space. " + "Please use one that is, such as 'random_search', 'hyperband'" + "'priorband', or 'complex_random_search'." + ) + + if isinstance(space, PipelineSpace): + assert not isinstance(evaluate_pipeline, str) + evaluate_pipeline = adjust_evaluation_pipeline_for_neps_space( + evaluate_pipeline, space + ) + _optimizer_ask, _optimizer_info = load_optimizer(optimizer=optimizer, space=space) multi_fidelity_optimizers = { @@ -491,6 +423,9 @@ def __call__( "moasha", "mo_hyperband", "primo", + "neps_priorband", + "neps_bracket_optimizer", + "neps_hyperband", } is_multi_fidelity = _optimizer_info["name"] in multi_fidelity_optimizers @@ -545,8 +480,7 @@ def __call__( post_run_csv(root_directory) root_directory = Path(root_directory) summary_dir = root_directory / "summary" - if not write_summary_to_disk: - trajectory_of_improvements(root_directory) + if write_summary_to_disk: logger.info( "The summary folder has been created, which contains csv and txt files with" "the output of all data in the run (short.csv - only the best; full.csv - " diff --git a/neps/optimizers/__init__.py b/neps/optimizers/__init__.py index 9b97790a9..e161c1f61 100644 --- a/neps/optimizers/__init__.py +++ b/neps/optimizers/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping +from functools import partial from typing import TYPE_CHECKING, Any, Concatenate, Literal from neps.optimizers.algorithms import ( @@ -14,11 +15,12 @@ if TYPE_CHECKING: from neps.space import SearchSpace + from neps.space.neps_spaces.parameters import PipelineSpace def _load_optimizer_from_string( optimizer: OptimizerChoice | Literal["auto"], - space: SearchSpace, + space: SearchSpace | PipelineSpace, *, optimizer_kwargs: Mapping[str, Any] | None = None, ) -> tuple[AskFunction, OptimizerInfo]: @@ -37,7 +39,7 @@ def _load_optimizer_from_string( keywords = extract_keyword_defaults(optimizer_build) optimizer_kwargs = optimizer_kwargs or {} - opt = optimizer_build(space, **optimizer_kwargs) + opt = optimizer_build(space, **optimizer_kwargs) # type: ignore info = OptimizerInfo(name=_optimizer, info={**keywords, **optimizer_kwargs}) return opt, info @@ -47,11 +49,13 @@ def load_optimizer( OptimizerChoice | Mapping[str, Any] | tuple[OptimizerChoice, Mapping[str, Any]] - | Callable[Concatenate[SearchSpace, ...], AskFunction] + | Callable[Concatenate[SearchSpace, ...], AskFunction] # Hack, while we transit + | Callable[Concatenate[PipelineSpace, ...], AskFunction] # from SearchSpace to + | Callable[Concatenate[SearchSpace | PipelineSpace, ...], AskFunction] # Pipeline | CustomOptimizer | Literal["auto"] ), - space: SearchSpace, + space: SearchSpace | PipelineSpace, ) -> tuple[AskFunction, OptimizerInfo]: match optimizer: # Predefined string (including "auto") @@ -68,9 +72,27 @@ def load_optimizer( # Provided optimizer initializer case _ if callable(optimizer): + inner_optimizer = None + if isinstance(optimizer, partial): + inner_optimizer = optimizer.func + while isinstance(inner_optimizer, partial): + inner_optimizer = inner_optimizer.func + else: + inner_optimizer = optimizer keywords = extract_keyword_defaults(optimizer) - _optimizer = optimizer(space) - info = OptimizerInfo(name=optimizer.__name__, info=keywords) + + # Error catch and type ignore needed while we transition from SearchSpace to + # Pipeline + try: + _optimizer = inner_optimizer(space, **keywords) # type: ignore + except TypeError as e: + raise TypeError( + f"Optimizer {inner_optimizer} does not accept a space of type" + f" {type(space)}." + ) from e + + info = OptimizerInfo(name=inner_optimizer.__name__, info=keywords) + return _optimizer, info # Custom optimizer, we create it diff --git a/neps/optimizers/algorithms.py b/neps/optimizers/algorithms.py index e80377e1e..b434aeb12 100644 --- a/neps/optimizers/algorithms.py +++ b/neps/optimizers/algorithms.py @@ -1,4 +1,6 @@ -"""The selection of optimization algorithms available in NePS. +"""NePS Algorithms +=========== +The selection of optimization algorithms available in NePS. This module conveniently starts with 'a' to be at the top and is where most of the code documentation for optimizers can be found. @@ -32,12 +34,31 @@ from neps.optimizers.ifbo import IFBO from neps.optimizers.models.ftpfn import FTPFNSurrogate from neps.optimizers.mopriors import MOPriorSampler +from neps.optimizers.neps_bracket_optimizer import _NePSBracketOptimizer +from neps.optimizers.neps_priorband import NePSPriorBandSampler +from neps.optimizers.neps_random_search import ( + NePSComplexRandomSearch, + NePSRandomSearch, +) from neps.optimizers.optimizer import AskFunction # noqa: TC001 from neps.optimizers.primo import PriMO from neps.optimizers.priorband import PriorBandSampler from neps.optimizers.random_search import RandomSearch from neps.sampling import Prior, Sampler, Uniform from neps.space.encoding import CategoricalToUnitNorm, ConfigEncoder +from neps.space.neps_spaces.neps_space import convert_neps_to_classic_search_space +from neps.space.neps_spaces.parameters import ( + Categorical, + Float, + Integer, + PipelineSpace, + Resolvable, +) +from neps.space.neps_spaces.sampling import ( + DomainSampler, + PriorOrFallbackSampler, + RandomSampler, +) from neps.space.parsing import convert_mapping if TYPE_CHECKING: @@ -46,11 +67,12 @@ from neps.optimizers.utils.brackets import Bracket from neps.space import SearchSpace + logger = logging.getLogger(__name__) -def _bo( - pipeline_space: SearchSpace, +def _bo( # noqa: C901, PLR0912 + pipeline_space: SearchSpace | PipelineSpace, *, initial_design_size: int | Literal["ndim"] = "ndim", use_priors: bool, @@ -87,6 +109,15 @@ def _bo( ValueError: if initial_design_size < 1 ValueError: if fidelity is not None and ignore_fidelity is False """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) if not ignore_fidelity and pipeline_space.fidelity is not None: raise ValueError( "Fidelities are not supported for BayesianOptimization. Consider setting the" @@ -135,14 +166,16 @@ def _bo( def _bracket_optimizer( # noqa: C901, PLR0912, PLR0915 - pipeline_space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, bracket_type: Literal["successive_halving", "hyperband", "asha", "async_hb"], eta: int, - sampler: Literal["uniform", "prior", "priorband", "mopriorsampler"] - | PriorBandSampler - | MOPriorSampler - | Sampler, + sampler: ( + Literal["uniform", "prior", "priorband", "mopriorsampler"] + | PriorBandSampler + | MOPriorSampler + | Sampler + ), bayesian_optimization_kick_in_point: int | float | None, sample_prior_first: bool | Literal["highest_fidelity"], # NOTE: This is the only argument to get a default, since it @@ -212,6 +245,15 @@ def _bracket_optimizer( # noqa: C901, PLR0912, PLR0915 multi_objective: Whether to use multi-objective promotion strategies. Only used in case of multi-objective multi-fidelity algorithms. """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) if pipeline_space.fidelity is not None: fidelity_name, fidelity = pipeline_space.fidelity else: @@ -389,7 +431,11 @@ def _bracket_optimizer( # noqa: C901, PLR0912, PLR0915 ) -def determine_optimizer_automatically(space: SearchSpace) -> str: +def determine_optimizer_automatically(space: SearchSpace | PipelineSpace) -> str: + if isinstance(space, PipelineSpace): + if space.fidelity_attrs: + return "neps_priorband" + return "complex_random_search" has_prior = any( parameter.prior is not None for parameter in space.searchables.values() ) @@ -409,11 +455,11 @@ def determine_optimizer_automatically(space: SearchSpace) -> str: def random_search( - pipeline_space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, use_priors: bool = False, ignore_fidelity: bool | Literal["highest fidelity"] = False, -) -> RandomSearch: +) -> RandomSearch | NePSRandomSearch: """A simple random search algorithm that samples configurations uniformly at random. You may also `use_priors=` to sample from a distribution centered around your defined @@ -423,8 +469,18 @@ def random_search( pipeline_space: The search space to sample from. use_priors: Whether to use priors when sampling. ignore_fidelity: Whether to ignore fidelity when sampling. - In this case, the max fidelity is always used. + Setting this to "highest fidelity" will always sample at max fidelity. + Setting this to True will randomly sample from the fidelity like any other + parameter. """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + return neps_random_search( + pipeline_space, use_priors=use_priors, ignore_fidelity=ignore_fidelity + ) assert ignore_fidelity in ( True, False, @@ -480,8 +536,10 @@ def random_search( def grid_search( - pipeline_space: SearchSpace, - ignore_fidelity: bool = False, # noqa: FBT001, FBT002 + pipeline_space: SearchSpace | PipelineSpace, + *, + ignore_fidelity: bool | Literal["highest fidelity"] = False, + size_per_numerical_dimension: int = 5, ) -> GridSearch: """A simple grid search algorithm which discretizes the search space and evaluates all possible configurations. @@ -489,27 +547,113 @@ def grid_search( Args: pipeline_space: The search space to sample from. ignore_fidelity: Whether to ignore fidelity when sampling. - In this case, the max fidelity is always used. + Setting this to "highest fidelity" will always sample at max fidelity. + Setting this to True will make a grid over the fidelity like any other + parameter. + size_per_numerical_dimension: The number of points to use per numerical + dimension when discretizing the space. """ from neps.optimizers.utils.grid import make_grid + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + return neps_grid_search( + pipeline_space, + ignore_fidelity=ignore_fidelity, + size_per_numerical_dimension=size_per_numerical_dimension, + ) + if any( parameter.prior is not None for parameter in pipeline_space.searchables.values() ): - raise ValueError("Grid search does not support priors.") + logger.warning("Grid search does not support priors, they will be ignored.") if ignore_fidelity and pipeline_space.fidelity is None: logger.warning( "Warning: You are using ignore_fidelity, but no fidelity is defined in the" " search space. Consider setting ignore_fidelity to False." ) + if not ignore_fidelity and pipeline_space.fidelity is not None: + raise ValueError( + "Fidelities are not supported for GridSearch natively. Consider setting the" + " fidelity to a constant value, or setting ignore_fidelity to True to sample" + " from it like any other parameter or 'highest fidelity' to always sample at" + f" max fidelity. Got fidelity: {pipeline_space.fidelities} " + ) return GridSearch( - configs_list=make_grid(pipeline_space, ignore_fidelity=ignore_fidelity) + configs_list=make_grid( + pipeline_space, + ignore_fidelity=ignore_fidelity, + size_per_numerical_hp=size_per_numerical_dimension, + ) + ) + + +def neps_grid_search( + pipeline_space: PipelineSpace, + *, + ignore_fidelity: bool | Literal["highest fidelity"] = False, + size_per_numerical_dimension: int = 5, +) -> GridSearch: + """A simple grid search algorithm which discretizes the search + space and evaluates all possible configurations. + + Args: + pipeline_space: The search space to sample from. + ignore_fidelity: Whether to ignore fidelity when sampling. + Setting this to "highest fidelity" will always sample at max fidelity. + Setting this to True will make a grid over the fidelity like any other + parameter. + size_per_numerical_dimension: The number of points to use per numerical + dimension when discretizing the space. + """ + from neps.optimizers.utils.grid import make_grid + + if not isinstance(pipeline_space, PipelineSpace): + raise ValueError( + "This optimizer only supports NePS spaces, please use a classic" + " search space-compatible optimizer." + ) + parameters = pipeline_space.get_attrs().values() + non_fid_parameters = [ + parameter + for parameter in parameters + if parameter not in pipeline_space.fidelity_attrs.values() + ] + if any( + parameter.has_prior # type: ignore + for parameter in non_fid_parameters + if isinstance(parameter, Resolvable) + and isinstance(parameter, Integer | Float | Categorical) + ): + logger.warning("Grid search does not support priors, they will be ignored.") + if not pipeline_space.fidelity_attrs and ignore_fidelity: + logger.warning( + "Warning: You are using ignore_fidelity, but no fidelity is defined in the" + " search space. Consider setting ignore_fidelity to False." + ) + if pipeline_space.fidelity_attrs and not ignore_fidelity: + raise ValueError( + "Fidelities are not supported for GridSearch natively. Consider setting the" + " fidelity to a constant value, or setting ignore_fidelity to True to sample" + " from it like any other parameter or 'highest fidelity' to always sample at" + f" max fidelity. Got fidelity: {pipeline_space.fidelity_attrs} " + ) + + return GridSearch( + configs_list=make_grid( + pipeline_space, + ignore_fidelity=ignore_fidelity, + size_per_numerical_hp=size_per_numerical_dimension, + ) ) def ifbo( - pipeline_space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, step_size: int | float = 1, use_priors: bool = False, @@ -559,6 +703,15 @@ def ifbo( surrogate_path: Path to the surrogate model to use surrogate_version: Version of the surrogate model to use """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) from neps.optimizers.ifbo import _adjust_space_to_match_stepsize if pipeline_space.fidelity is None: @@ -628,7 +781,7 @@ def ifbo( def successive_halving( - space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, sampler: Literal["uniform", "prior"] = "uniform", eta: int = 3, @@ -681,7 +834,7 @@ def successive_halving( or `#!python sampler="prior"`. Args: - space: The search space to sample from. + pipeline_space: The search space to sample from. eta: The reduction factor used for building brackets early_stopping_rate: Determines the number of rungs in a bracket Choosing 0 creates maximal rungs given the fidelity bounds. @@ -696,8 +849,17 @@ def successive_halving( sample_prior_first: Whether to sample the prior configuration first, and if so, should it be at the highest fidelity level. """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) return _bracket_optimizer( - pipeline_space=space, + pipeline_space=pipeline_space, bracket_type="successive_halving", eta=eta, early_stopping_rate=early_stopping_rate, @@ -710,12 +872,12 @@ def successive_halving( def hyperband( - space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, eta: int = 3, sampler: Literal["uniform", "prior"] = "uniform", sample_prior_first: bool | Literal["highest_fidelity"] = False, -) -> BracketOptimizer: +) -> BracketOptimizer | _NePSBracketOptimizer: """Another bandit-based optimization algorithm that uses a _fidelity_ parameter, very similar to [`successive_halving`][neps.optimizers.algorithms.successive_halving], but hedges a bit more on the safe side, just incase your _fidelity_ parameters @@ -747,7 +909,7 @@ def hyperband( as this algorithm could be considered an extension of it. Args: - space: The search space to sample from. + pipeline_space: The search space to sample from. eta: The reduction factor used for building brackets sampler: The type of sampling procedure to use: @@ -760,8 +922,19 @@ def hyperband( sample_prior_first: Whether to sample the prior configuration first, and if so, should it be at the highest fidelity level. """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space: + pipeline_space = converted_space + else: + return neps_hyperband( + pipeline_space, + eta=eta, + sampler=sampler, + sample_prior_first=sample_prior_first, + ) return _bracket_optimizer( - pipeline_space=space, + pipeline_space=pipeline_space, bracket_type="hyperband", eta=eta, sampler=sampler, @@ -773,8 +946,41 @@ def hyperband( ) +def neps_hyperband( + pipeline_space: PipelineSpace, + *, + eta: int = 3, + sampler: Literal["uniform", "prior"] = "uniform", + sample_prior_first: bool | Literal["highest_fidelity"] = False, +) -> _NePSBracketOptimizer: + """ + Hyperband optimizer for NePS search spaces. + Args: + pipeline_space: The search space to sample from. + eta: The reduction factor used for building brackets + sampler: The type of sampling procedure to use: + + * If `#!python "uniform"`, samples uniformly from the space when + it needs to sample. + * If `#!python "prior"`, samples from the prior + distribution built from the `prior` and `prior_confidence` + values in the search space. + + sample_prior_first: Whether to sample the prior configuration first, + and if so, should it be at the highest fidelity level. + """ + return _neps_bracket_optimizer( + pipeline_space=pipeline_space, + bracket_type="hyperband", + eta=eta, + sampler="prior" if sampler == "prior" else "uniform", + sample_prior_first=sample_prior_first, + early_stopping_rate=None, + ) + + def mo_hyperband( - space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, eta: int = 3, sampler: Literal["uniform", "prior"] = "uniform", @@ -784,8 +990,17 @@ def mo_hyperband( """Multi-objective version of hyperband using the same candidate selection method as MOASHA. """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) return _bracket_optimizer( - pipeline_space=space, + pipeline_space=pipeline_space, bracket_type="hyperband", eta=eta, sampler=sampler, @@ -800,7 +1015,7 @@ def mo_hyperband( def asha( - space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, eta: int = 3, early_stopping_rate: int = 0, @@ -836,7 +1051,7 @@ def asha( as this algorithm could be considered an extension of it. Args: - space: The search space to sample from. + pipeline_space: The search space to sample from. eta: The reduction factor used for building brackets sampler: The type of sampling procedure to use: @@ -849,9 +1064,17 @@ def asha( sample_prior_first: Whether to sample the prior configuration first, and if so, should it be at the highest fidelity. """ - + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) return _bracket_optimizer( - pipeline_space=space, + pipeline_space=pipeline_space, bracket_type="asha", eta=eta, early_stopping_rate=early_stopping_rate, @@ -864,7 +1087,7 @@ def asha( def moasha( - space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, eta: int = 3, early_stopping_rate: int = 0, @@ -872,8 +1095,17 @@ def moasha( sample_prior_first: bool | Literal["highest_fidelity"] = False, mo_selector: Literal["nsga2", "epsnet"] = "epsnet", ) -> BracketOptimizer: + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) return _bracket_optimizer( - pipeline_space=space, + pipeline_space=pipeline_space, bracket_type="asha", eta=eta, early_stopping_rate=early_stopping_rate, @@ -888,7 +1120,7 @@ def moasha( def async_hb( - space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, eta: int = 3, sampler: Literal["uniform", "prior"] = "uniform", @@ -922,7 +1154,7 @@ def async_hb( takes elements from each. Args: - space: The search space to sample from. + pipeline_space: The search space to sample from. eta: The reduction factor used for building brackets sampler: The type of sampling procedure to use: @@ -934,8 +1166,17 @@ def async_hb( sample_prior_first: Whether to sample the prior configuration first. """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) return _bracket_optimizer( - pipeline_space=space, + pipeline_space=pipeline_space, bracket_type="async_hb", eta=eta, sampler=sampler, @@ -948,13 +1189,13 @@ def async_hb( def priorband( - space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, eta: int = 3, sample_prior_first: bool | Literal["highest_fidelity"] = False, base: Literal["successive_halving", "hyperband", "asha", "async_hb"] = "hyperband", bayesian_optimization_kick_in_point: int | float | None = None, -) -> BracketOptimizer: +) -> BracketOptimizer | _NePSBracketOptimizer: """Priorband is also a bandit-based optimization algorithm that uses a _fidelity_, providing a general purpose sampling extension to other algorithms. It makes better use of the prior information you provide in the search space along with the fact @@ -984,7 +1225,7 @@ def priorband( See: https://openreview.net/forum?id=uoiwugtpCH¬eId=xECpK2WH6k Args: - space: The search space to sample from. + pipeline_space: The search space to sample from. eta: The reduction factor used for building brackets sample_prior_first: Whether to sample the prior configuration first. base: The base algorithm to use for the bracketing. @@ -992,13 +1233,29 @@ def priorband( `N` * `maximum_fidelity` worth of fidelity has been evaluated, proceed with bayesian optimization when sampling a new configuration. """ - if all(parameter.prior is None for parameter in space.searchables.values()): + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + if bayesian_optimization_kick_in_point is not None: + raise ValueError( + "The priorband variant for this complex search space does not" + " support a bayesian optimization kick-in point yet." + ) + return neps_priorband( + pipeline_space, + eta=eta, + sample_prior_first=sample_prior_first, + base=base, + ) + if all(parameter.prior is None for parameter in pipeline_space.searchables.values()): logger.warning( "Warning: No priors are defined in the search space, priorband will sample" " uniformly. Consider using hyperband instead." ) return _bracket_optimizer( - pipeline_space=space, + pipeline_space=pipeline_space, bracket_type=base, eta=eta, sampler="priorband", @@ -1010,7 +1267,7 @@ def priorband( def bayesian_optimization( - space: SearchSpace, + pipeline_space: SearchSpace, *, initial_design_size: int | Literal["ndim"] = "ndim", cost_aware: bool | Literal["log"] = False, @@ -1047,7 +1304,7 @@ def bayesian_optimization( acquisition function. Args: - space: The search space to sample from. + pipeline_space: The search space to sample from. initial_design_size: Number of samples used before using the surrogate model. If "ndim", it will use the number of parameters in the search space. cost_aware: Whether to consider reported "cost" from configurations in decision @@ -1068,23 +1325,34 @@ def bayesian_optimization( optimization. If `None`, the reference point will be calculated automatically. """ + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) - if not ignore_fidelity and space.fidelity is not None: + if not ignore_fidelity and pipeline_space.fidelity is not None: raise ValueError( "Fidelities are not supported for BayesianOptimization. Consider setting the" " fidelity to a constant value or ignoring it using ignore_fidelity to" - f" always sample at max fidelity. Got fidelity: {space.fidelities} " + f" always sample at max fidelity. Got fidelity: {pipeline_space.fidelities} " ) - if ignore_fidelity and space.fidelity is None: + if ignore_fidelity and pipeline_space.fidelity is None: logger.warning( "Warning: You are using ignore_fidelity, but no fidelity is defined in the" " search space. Consider setting ignore_fidelity to False." ) - if any(parameter.prior is not None for parameter in space.searchables.values()): + if any( + parameter.prior is not None for parameter in pipeline_space.searchables.values() + ): priors = [ parameter - for parameter in space.searchables.values() + for parameter in pipeline_space.searchables.values() if parameter.prior is not None ] raise ValueError( @@ -1093,7 +1361,7 @@ def bayesian_optimization( ) return _bo( - pipeline_space=space, + pipeline_space=pipeline_space, initial_design_size=initial_design_size, cost_aware=cost_aware, device=device, @@ -1105,7 +1373,7 @@ def bayesian_optimization( def pibo( - space: SearchSpace, + pipeline_space: SearchSpace | PipelineSpace, *, initial_design_size: int | Literal["ndim"] = "ndim", cost_aware: bool | Literal["log"] = False, @@ -1127,7 +1395,7 @@ def pibo( has. Args: - space: The search space to sample from. + pipeline_space: The search space to sample from. initial_design_size: Number of samples used before using the surrogate model. If "ndim", it will use the number of parameters in the search space. cost_aware: Whether to consider reported "cost" from configurations in decision @@ -1135,27 +1403,37 @@ def pibo( they cost, incentivising the optimizer to explore cheap, good performing configurations. This amount is modified over time. If "log", the cost will be log-transformed before being used. - !!! warning + !!! warning "Cost aware" + + If using `cost`, cost must be provided in the reports of the trials. - If using `cost`, cost must be provided in the reports of the trials. device: Device to use for the optimization. sample_prior_first: Whether to sample the prior configuration first. ignore_fidelity: Whether to ignore the fidelity parameter when sampling. In this case, the max fidelity is always used. """ - if all(parameter.prior is None for parameter in space.searchables.values()): + if isinstance(pipeline_space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(pipeline_space) + if converted_space is not None: + pipeline_space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) + if all(parameter.prior is None for parameter in pipeline_space.searchables.values()): logger.warning( "Warning: PiBO was called without any priors - using uniform priors on all" " parameters.\nConsider using Bayesian Optimization instead." ) - if ignore_fidelity and space.fidelity is None: + if ignore_fidelity and pipeline_space.fidelity is None: logger.warning( "Warning: You are using ignore_fidelity, but no fidelity is defined in the" " search space. Consider setting ignore_fidelity to False." ) return _bo( - pipeline_space=space, + pipeline_space=pipeline_space, initial_design_size=initial_design_size, cost_aware=cost_aware, device=device, @@ -1248,7 +1526,7 @@ class CustomOptimizer: kwargs: Mapping[str, Any] = field(default_factory=dict) initialized: bool = False - def create(self, space: SearchSpace) -> AskFunction: + def create(self, space: SearchSpace | PipelineSpace) -> AskFunction: assert not self.initialized, "Custom optimizer already initialized." return self.optimizer(space, **self.kwargs) # type: ignore @@ -1276,10 +1554,260 @@ def custom( ) -PredefinedOptimizers: Mapping[ - str, - Callable[Concatenate[SearchSpace, ...], AskFunction], -] = { +def complex_random_search( + pipeline_space: PipelineSpace, + *, + ignore_fidelity: bool | Literal["highest fidelity"] = False, +) -> NePSComplexRandomSearch: + """A complex random search algorithm that samples configurations uniformly at random, + but allows for more complex sampling strategies. + + Args: + pipeline_space: The search space to sample from. + ignore_fidelity: Whether to ignore the fidelity parameter when sampling. + If `True`, the algorithm will sample the fidelity like a normal parameter. + If set to `"highest fidelity"`, it will always sample at the highest fidelity. + Raises: + ValueError: If the pipeline has fidelity attributes and `ignore_fidelity` is + set to `False`. Complex random search does not support fidelities by default. + """ + + if pipeline_space.fidelity_attrs and ignore_fidelity is False: + raise ValueError( + "Complex Random Search does not support fidelities by default." + "Consider using `ignore_fidelity=True` or `highest fidelity`" + "to always sample at max fidelity." + ) + if not pipeline_space.fidelity_attrs and ignore_fidelity is not False: + logger.warning( + "You are using ignore_fidelity, but no fidelity is defined in the" + " search space. Consider setting ignore_fidelity to False." + ) + + return NePSComplexRandomSearch( + pipeline=pipeline_space, + ignore_fidelity=ignore_fidelity, + ) + + +def neps_random_search( + pipeline_space: PipelineSpace, + *, + use_priors: bool = False, + ignore_fidelity: bool | Literal["highest fidelity"] = False, +) -> NePSRandomSearch: + """A simple random search algorithm that samples configurations uniformly at random. + + Args: + pipeline_space: The search space to sample from. + use_priors: Whether to use priors when sampling. + If `True`, the algorithm will sample from the prior distribution + defined in the search space. + ignore_fidelity: Whether to ignore the fidelity parameter when sampling. + If `True`, the algorithm will sample the fidelity like a normal parameter. + If set to `"highest fidelity"`, it will always sample at the highest fidelity. + Raises: + ValueError: If the pipeline space has fidelity attributes and `ignore_fidelity` is + set to `False`. Random search does not support fidelities by default. + """ + + if pipeline_space.fidelity_attrs and ignore_fidelity is False: + raise ValueError( + "Random Search does not support fidelities by default." + "Consider using `ignore_fidelity=True` or `highest fidelity`" + "to always sample at max fidelity." + ) + if not pipeline_space.fidelity_attrs and ignore_fidelity is not False: + logger.warning( + "You are using ignore_fidelity, but no fidelity is defined in the" + " search space. Consider setting ignore_fidelity to False." + ) + parameters = pipeline_space.get_attrs().values() + non_fid_parameters = [ + parameter + for parameter in parameters + if parameter not in pipeline_space.fidelity_attrs.values() + ] + if use_priors and not any( + parameter.has_prior # type: ignore + for parameter in non_fid_parameters + if isinstance(parameter, Resolvable) + and isinstance(parameter, Integer | Float | Categorical) + ): + logger.warning( + "You have set use_priors=True, but no priors are defined in the search space." + ) + + return NePSRandomSearch( + pipeline=pipeline_space, use_priors=use_priors, ignore_fidelity=ignore_fidelity + ) + + +def _neps_bracket_optimizer( + pipeline_space: PipelineSpace, + *, + bracket_type: Literal["successive_halving", "hyperband", "asha", "async_hb"], + eta: int, + sampler: Literal["priorband", "uniform", "prior"], + sample_prior_first: bool | Literal["highest_fidelity"], + early_stopping_rate: int | None, + inc_ratio: float = 0.9, +) -> _NePSBracketOptimizer: + fidelity_attrs = pipeline_space.fidelity_attrs + + if len(fidelity_attrs.items()) != 1: + raise ValueError( + "Exactly one fidelity should be defined in the pipeline space." + f"\nGot: {fidelity_attrs!r}" + ) + + fidelity_name, fidelity_obj = next(iter(fidelity_attrs.items())) + + if sample_prior_first not in (True, False, "highest_fidelity"): + raise ValueError( + "sample_prior_first should be either True, False or 'highest_fidelity'" + ) + + from neps.optimizers.utils import brackets + + # Determine the strategy for creating brackets for sampling + create_brackets: Callable[[pd.DataFrame], Sequence[Bracket] | Bracket] + match bracket_type: + case "successive_halving": + assert early_stopping_rate is not None + rung_to_fidelity, rung_sizes = brackets.calculate_sh_rungs( + bounds=(fidelity_obj.min_value, fidelity_obj.max_value), + eta=eta, + early_stopping_rate=early_stopping_rate, + ) + create_brackets = partial( + brackets.Sync.create_repeating, + rung_sizes=rung_sizes, + ) + + case "hyperband": + assert early_stopping_rate is None + rung_to_fidelity, bracket_layouts = brackets.calculate_hb_bracket_layouts( + bounds=(fidelity_obj.min_value, fidelity_obj.max_value), + eta=eta, + ) + create_brackets = partial( + brackets.Hyperband.create_repeating, + bracket_layouts=bracket_layouts, + ) + + case "asha": + assert early_stopping_rate is not None + rung_to_fidelity, _rung_sizes = brackets.calculate_sh_rungs( + bounds=(fidelity_obj.min_value, fidelity_obj.max_value), + eta=eta, + early_stopping_rate=early_stopping_rate, + ) + create_brackets = partial( + brackets.Async.create, + rungs=list(rung_to_fidelity), + eta=eta, + ) + + case "async_hb": + assert early_stopping_rate is None + rung_to_fidelity, bracket_layouts = brackets.calculate_hb_bracket_layouts( + bounds=(fidelity_obj.min_value, fidelity_obj.max_value), + eta=eta, + ) + # We don't care about the capacity of each bracket, we need the rung layout + bracket_rungs = [list(bracket.keys()) for bracket in bracket_layouts] + create_brackets = partial( + brackets.AsyncHyperband.create, + bracket_rungs=bracket_rungs, + eta=eta, + ) + case _: + raise ValueError(f"Unknown bracket type: {bracket_type}") + + _sampler: NePSPriorBandSampler | DomainSampler + match sampler: + case "priorband": + _sampler = NePSPriorBandSampler( + space=pipeline_space, + eta=eta, + early_stopping_rate=( + early_stopping_rate if early_stopping_rate is not None else 0 + ), + fid_bounds=(fidelity_obj.min_value, fidelity_obj.max_value), + inc_ratio=inc_ratio, + ) + case "uniform": + _sampler = RandomSampler({}) + case "prior": + _sampler = PriorOrFallbackSampler( + fallback_sampler=RandomSampler({}), always_use_prior=False + ) + case _: + raise ValueError(f"Unknown sampler: {sampler}") + + return _NePSBracketOptimizer( + space=pipeline_space, + eta=eta, + rung_to_fid=rung_to_fidelity, + sampler=_sampler, + sample_prior_first=sample_prior_first, + create_brackets=create_brackets, + ) + + +def neps_priorband( + pipeline_space: PipelineSpace, + *, + inc_ratio: float = 0.9, + eta: int = 3, + sample_prior_first: bool | Literal["highest_fidelity"] = False, + base: Literal["successive_halving", "hyperband", "asha", "async_hb"] = "hyperband", +) -> _NePSBracketOptimizer: + """Create a PriorBand optimizer for the given pipeline space. + + Args: + pipeline_space: The pipeline space to optimize over. + eta: The eta parameter for the algorithm. + sample_prior_first: Whether to sample the prior first. + If set to `"highest_fidelity"`, the prior will be sampled at the + highest fidelity, otherwise at the lowest fidelity. + base: The type of bracket optimizer to use. One of: + - "successive_halving" + - "hyperband" + - "asha" + - "async_hb" + Returns: + An instance of _BracketOptimizer configured for PriorBand sampling. + """ + parameters = pipeline_space.get_attrs().values() + non_fid_parameters = [ + parameter + for parameter in parameters + if parameter not in pipeline_space.fidelity_attrs.values() + and isinstance(parameter, Integer | Float | Categorical) + ] + if not any( + parameter.has_prior # type: ignore + for parameter in non_fid_parameters + if isinstance(parameter, Resolvable) + ): + logger.warning( + "Warning: No priors are defined in the search space, priorband will sample" + " uniformly. Consider using hyperband instead." + ) + return _neps_bracket_optimizer( + pipeline_space=pipeline_space, + bracket_type=base, + eta=eta, + sampler="priorband", + sample_prior_first=sample_prior_first, + early_stopping_rate=0 if base in ("successive_halving", "asha") else None, + inc_ratio=inc_ratio, + ) + + +PredefinedOptimizers: Mapping[str, Any] = { f.__name__: f for f in ( bayesian_optimization, @@ -1295,6 +1823,10 @@ def custom( async_hb, priorband, primo, + neps_random_search, + complex_random_search, + neps_priorband, + neps_hyperband, ) } @@ -1312,4 +1844,8 @@ def custom( "grid_search", "ifbo", "primo", + "neps_random_search", + "complex_random_search", + "neps_priorband", + "neps_hyperband", ] diff --git a/neps/optimizers/ask_and_tell.py b/neps/optimizers/ask_and_tell.py index b3a02fbcd..eacce1268 100644 --- a/neps/optimizers/ask_and_tell.py +++ b/neps/optimizers/ask_and_tell.py @@ -72,6 +72,7 @@ def evaluate(config): import time from collections.abc import Mapping from dataclasses import dataclass, field +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, overload from neps.optimizers.optimizer import AskFunction, SampledConfig @@ -79,7 +80,6 @@ def evaluate(config): if TYPE_CHECKING: from neps.state.optimizer import BudgetInfo - from neps.state.pipeline_eval import EvaluatePipelineReturn def _default_worker_name() -> str: @@ -180,6 +180,7 @@ def tell_custom( previous_trial_id: str | None = None, worker_id: str | None = None, traceback_str: str | None = None, + location: Path | None = None, ) -> Trial: """Report a custom configuration and result to the optimizer. @@ -207,6 +208,8 @@ def tell_custom( metadata if you need. traceback_str: The traceback of any error, only to fill in metadata if you need. + location: The location of the configuration, if any. This will be saved + in the created trial's metadata. Returns: The trial object that was created. You can find the report @@ -232,7 +235,7 @@ def tell_custom( # Just go through the motions of the trial life-cycle trial = Trial.new( trial_id=config_id, - location="", + location=str(location.resolve()) if location else "", config=config, previous_trial=previous_trial_id, previous_trial_location="", @@ -269,8 +272,8 @@ def tell( """Report the result of an evaluation back to the optimizer. Args: - config_id: The id of the configuration you got from - [`ask()`][neps.optimizers.ask_and_tell.AskAndTell.ask]. + trial: The trial to report the result for. This can be either + the trial id (a string) or the trial object itself. result: The result of the evaluation. This can be an exception, a float, or a mapping of values, similar to that which you would return from `evaluate_pipeline` when your normally diff --git a/neps/optimizers/bayesian_optimization.py b/neps/optimizers/bayesian_optimization.py index 1160a9fb2..71150cb5e 100644 --- a/neps/optimizers/bayesian_optimization.py +++ b/neps/optimizers/bayesian_optimization.py @@ -23,6 +23,8 @@ ) from neps.optimizers.optimizer import ImportedConfig, SampledConfig from neps.optimizers.utils.initial_design import make_initial_design +from neps.space.neps_spaces.neps_space import convert_neps_to_classic_search_space +from neps.space.neps_spaces.parameters import PipelineSpace if TYPE_CHECKING: from neps.sampling import Prior @@ -65,7 +67,7 @@ def _pibo_exp_term( class BayesianOptimization: """Uses `botorch` as an engine for doing bayesian optimiziation.""" - space: SearchSpace + space: SearchSpace | PipelineSpace """The search space to use.""" encoder: ConfigEncoder @@ -95,6 +97,16 @@ def __call__( # noqa: C901, PLR0912, PLR0915 # noqa: C901, PLR0912 budget_info: BudgetInfo | None = None, n: int | None = None, ) -> SampledConfig | list[SampledConfig]: + if isinstance(self.space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(self.space) + if converted_space is not None: + self.space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) + # If fidelities exist, sample from them as normal # This is a bit of a hack, as we set them to max fidelity # afterwards, but we need the complete space to sample diff --git a/neps/optimizers/bracket_optimizer.py b/neps/optimizers/bracket_optimizer.py index c357b8419..b3f9f8461 100644 --- a/neps/optimizers/bracket_optimizer.py +++ b/neps/optimizers/bracket_optimizer.py @@ -26,6 +26,8 @@ get_trial_config_unique_key, ) from neps.sampling.samplers import Sampler +from neps.space.neps_spaces.neps_space import convert_neps_to_classic_search_space +from neps.space.neps_spaces.parameters import PipelineSpace from neps.utils.common import disable_warnings if TYPE_CHECKING: @@ -219,7 +221,7 @@ class BracketOptimizer: `"successive_halving"`, `"asha"`, `"hyperband"`, etc. """ - space: SearchSpace + space: SearchSpace | PipelineSpace """The pipeline space to optimize over.""" encoder: ConfigEncoder @@ -258,12 +260,22 @@ class BracketOptimizer: fid_name: str """The name of the fidelity in the space.""" - def __call__( # noqa: C901, PLR0912 + def __call__( # noqa: C901, PLR0912, PLR0915 self, trials: Mapping[str, Trial], budget_info: BudgetInfo | None, n: int | None = None, ) -> SampledConfig | list[SampledConfig]: + if isinstance(self.space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(self.space) + if converted_space is not None: + self.space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) + assert n is None, "paramter n should be not None" space = self.space parameters = space.searchables diff --git a/neps/optimizers/ifbo.py b/neps/optimizers/ifbo.py index 6ab898a95..66dce4a4a 100755 --- a/neps/optimizers/ifbo.py +++ b/neps/optimizers/ifbo.py @@ -18,7 +18,9 @@ from neps.optimizers.utils.initial_design import make_initial_design from neps.optimizers.utils.util import get_trial_config_unique_key from neps.sampling import Prior, Sampler -from neps.space import ConfigEncoder, Domain, Float, Integer, SearchSpace +from neps.space import ConfigEncoder, Domain, HPOFloat, HPOInteger, SearchSpace +from neps.space.neps_spaces.neps_space import convert_neps_to_classic_search_space +from neps.space.neps_spaces.parameters import PipelineSpace if TYPE_CHECKING: from neps.state import BudgetInfo, Trial @@ -73,10 +75,10 @@ def _adjust_space_to_match_stepsize( r = x - n * step_size new_lower = fidelity.lower + r - new_fid: Float | Integer + new_fid: HPOFloat | HPOInteger match fidelity: - case Float(): - new_fid = Float( + case HPOFloat(): + new_fid = HPOFloat( lower=float(new_lower), upper=float(fidelity.upper), log=fidelity.log, @@ -84,8 +86,8 @@ def _adjust_space_to_match_stepsize( is_fidelity=True, prior_confidence=fidelity.prior_confidence, ) - case Integer(): - new_fid = Integer( + case HPOInteger(): + new_fid = HPOInteger( lower=int(new_lower), upper=int(fidelity.upper), log=fidelity.log, @@ -107,7 +109,7 @@ class IFBO: * Github: https://github.com/automl/ifBO/tree/main """ - space: SearchSpace + space: SearchSpace | PipelineSpace """The entire search space for the pipeline.""" encoder: ConfigEncoder @@ -140,6 +142,15 @@ def __call__( budget_info: BudgetInfo | None = None, n: int | None = None, ) -> SampledConfig | list[SampledConfig]: + if isinstance(self.space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(self.space) + if converted_space is not None: + self.space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) assert self.space.fidelity is not None fidelity_name, fidelity = self.space.fidelity parameters = self.space.searchables @@ -297,6 +308,7 @@ def import_trials( return [] imported: list[ImportedConfig] = [] + assert isinstance(self.space, SearchSpace) assert self.space.fidelity is not None fidelity_name, fidelity = self.space.fidelity budget_index_domain = Domain.indices(self.n_fidelity_bins + 1) diff --git a/neps/optimizers/models/ftpfn.py b/neps/optimizers/models/ftpfn.py index 72e88ce7a..b69337be8 100644 --- a/neps/optimizers/models/ftpfn.py +++ b/neps/optimizers/models/ftpfn.py @@ -10,7 +10,7 @@ from neps.sampling import Prior, Sampler if TYPE_CHECKING: - from neps.space import ConfigEncoder, Domain, Float, Integer + from neps.space import ConfigEncoder, Domain, HPOFloat, HPOInteger from neps.state.trial import Trial @@ -106,7 +106,7 @@ def _cast_tensor_shapes(x: torch.Tensor) -> torch.Tensor: def encode_ftpfn( trials: Mapping[str, Trial], - fid: tuple[str, Integer | Float], + fid: tuple[str, HPOInteger | HPOFloat], budget_domain: Domain, encoder: ConfigEncoder, *, @@ -131,7 +131,6 @@ def encode_ftpfn( Args: trials: The trials to encode encoder: The encoder to use - space: The search space budget_domain: The domain to use for the budgets of the FTPFN device: The device to use dtype: The dtype to use @@ -169,12 +168,14 @@ def encode_ftpfn( # We could possibly include some bounded transform to assert this. minimize_ys = torch.tensor( [ - pending_value - if trial.report is None - else ( - error_value - if trial.report.objective_to_minimize is None - else trial.report.objective_to_minimize + ( + pending_value + if trial.report is None + else ( + error_value + if trial.report.objective_to_minimize is None + else trial.report.objective_to_minimize + ) ) for trial in trials.values() ], diff --git a/neps/optimizers/models/gp.py b/neps/optimizers/models/gp.py index 586ba371e..a70a235bb 100644 --- a/neps/optimizers/models/gp.py +++ b/neps/optimizers/models/gp.py @@ -248,7 +248,6 @@ def encode_trials_for_gp( Args: trials: The trials to encode. - space: The search space. encoder: The encoder to use. If `None`, one will be created. device: The device to use. diff --git a/neps/optimizers/neps_bracket_optimizer.py b/neps/optimizers/neps_bracket_optimizer.py new file mode 100644 index 000000000..f48d17d1f --- /dev/null +++ b/neps/optimizers/neps_bracket_optimizer.py @@ -0,0 +1,214 @@ +"""This module provides multi-fidelity optimizers for NePS spaces. +It implements a bracket-based optimization strategy that samples configurations +from a prior band, allowing for efficient exploration of the search space. +It supports different bracket types such as successive halving, hyperband, ASHA, +and async hyperband, and can sample configurations at different fidelity levels. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +import pandas as pd + +import neps.optimizers.bracket_optimizer as standard_bracket_optimizer +from neps.optimizers.neps_priorband import NePSPriorBandSampler +from neps.optimizers.optimizer import SampledConfig +from neps.optimizers.utils.brackets import PromoteAction, SampleAction +from neps.space.neps_spaces import neps_space +from neps.space.neps_spaces.sampling import ( + DomainSampler, + OnlyPredefinedValuesSampler, + PriorOrFallbackSampler, + RandomSampler, +) + +if TYPE_CHECKING: + from neps.optimizers.utils.brackets import Bracket + from neps.space.neps_spaces.parameters import PipelineSpace + from neps.state.optimizer import BudgetInfo + from neps.state.trial import Trial + + +logger = logging.getLogger(__name__) + + +@dataclass +class _NePSBracketOptimizer: + """The pipeline space to optimize over.""" + + space: PipelineSpace + + """Whether or not to sample the prior first. + + If set to `"highest_fidelity"`, the prior will be sampled at the highest fidelity, + otherwise at the lowest fidelity. + """ + sample_prior_first: bool | Literal["highest_fidelity"] + + """The eta parameter for the algorithm.""" + eta: int + + """The mapping from rung to fidelity value.""" + rung_to_fid: Mapping[int, int | float] + + """A function that creates the brackets from the table of trials.""" + create_brackets: Callable[[pd.DataFrame], Sequence[Bracket] | Bracket] + + """The sampler used to generate new trials.""" + sampler: NePSPriorBandSampler | DomainSampler + + def __call__( # noqa: C901, PLR0912 + self, + trials: Mapping[str, Trial], + budget_info: BudgetInfo | None, + n: int | None = None, + ) -> SampledConfig | list[SampledConfig]: + assert n is None, "TODO" + + # If we have no trials, we either go with the prior or just a sampled config + if len(trials) == 0: + match self.sample_prior_first: + case "highest_fidelity": # fid_max + config = self._sample_prior(fidelity_level="max") + rung = max(self.rung_to_fid) + return SampledConfig(id=f"1_rung_{rung}", config=config) + case True: # fid_min + config = self._sample_prior(fidelity_level="min") + rung = min(self.rung_to_fid) + return SampledConfig(id=f"1__rung_{rung}", config=config) + case False: + pass + + table = standard_bracket_optimizer.trials_to_table(trials=trials) + + if len(table) == 0: # noqa: SIM108 + # Nothing there, this sample will be the first + nxt_id = 1 + else: + # One plus the maximum current id in the table index + nxt_id = table.index.get_level_values("id").max() + 1 # type: ignore + + # We don't want the first highest fidelity sample ending + # up in a bracket + if self.sample_prior_first == "highest_fidelity": + table = table.iloc[1:] + + # Get and execute the next action from our brackets that are not pending or done + assert isinstance(table, pd.DataFrame) + brackets = self.create_brackets(table) + + if not isinstance(brackets, Sequence): + brackets = [brackets] + + next_action = next( + ( + action + for bracket in brackets + if (action := bracket.next()) not in ("done", "pending") + ), + None, + ) + + if next_action is None: + raise RuntimeError( + f"{self.__class__.__name__} never got a 'sample' or 'promote' action!" + f" This likely means the implementation of {self.create_brackets}" + " is incorrect and should have provded enough brackets, where at" + " least one of them should have requested another sample." + f"\nBrackets:\n{brackets}" + ) + + match next_action: + # The bracket would like us to promote a configuration + case PromoteAction(config=config, id=config_id, new_rung=new_rung): + config = self._convert_to_another_rung(config=config, rung=new_rung) + return SampledConfig( + id=f"{config_id}_rung_{new_rung}", + config=config, + previous_config_id=f"{config_id}_rung_{new_rung - 1}", + ) + + # We need to sample for a new rung. + case SampleAction(rung=rung): + if isinstance(self.sampler, NePSPriorBandSampler): + config = self.sampler.sample_config(table, rung=rung) + elif isinstance(self.sampler, DomainSampler): + environment_values = {} + fidelity_attrs = self.space.fidelity_attrs + assert len(fidelity_attrs) == 1, "TODO: [lum]" + for fidelity_name, _fidelity_obj in fidelity_attrs.items(): + environment_values[fidelity_name] = self.rung_to_fid[rung] + _, resolution_context = neps_space.resolve( + self.space, + domain_sampler=self.sampler, + environment_values=environment_values, + ) + config = neps_space.NepsCompatConverter.to_neps_config( # type: ignore[assignment] + resolution_context + ) + config = dict(**config) + config = self._convert_to_another_rung(config=config, rung=rung) + return SampledConfig( + id=f"{nxt_id}_rung_{rung}", + config=config, + ) + + case _: + raise RuntimeError(f"Unknown bracket action: {next_action}") + + def _sample_prior( + self, + fidelity_level: Literal["min"] | Literal["max"], + ) -> dict[str, Any]: + # TODO: [lum] have a CenterSampler as fallback, not Random + _try_always_priors_sampler = PriorOrFallbackSampler( + fallback_sampler=RandomSampler(predefined_samplings={}), + always_use_prior=True, + ) + + _environment_values = {} + _fidelity_attrs = self.space.fidelity_attrs + for fidelity_name, fidelity_obj in _fidelity_attrs.items(): + if fidelity_level == "max": + _environment_values[fidelity_name] = fidelity_obj.max_value + elif fidelity_level == "min": + _environment_values[fidelity_name] = fidelity_obj.min_value + else: + raise ValueError(f"Invalid fidelity level {fidelity_level}") + + _resolved_pipeline, resolution_context = neps_space.resolve( + pipeline=self.space, + domain_sampler=_try_always_priors_sampler, + environment_values=_environment_values, + ) + + config = neps_space.NepsCompatConverter.to_neps_config(resolution_context) + return dict(**config) + + def _convert_to_another_rung( + self, + config: Mapping[str, Any], + rung: int, + ) -> dict[str, Any]: + data = neps_space.NepsCompatConverter.from_neps_config(config=config) + + _environment_values = {} + _fidelity_attrs = self.space.fidelity_attrs + assert len(_fidelity_attrs) == 1, "TODO: [lum]" + for fidelity_name, _fidelity_obj in _fidelity_attrs.items(): + _environment_values[fidelity_name] = self.rung_to_fid[rung] + + _resolved_pipeline, resolution_context = neps_space.resolve( + pipeline=self.space, + domain_sampler=OnlyPredefinedValuesSampler( + predefined_samplings=data.predefined_samplings, + ), + environment_values=_environment_values, + ) + + config = neps_space.NepsCompatConverter.to_neps_config(resolution_context) + return dict(**config) diff --git a/neps/optimizers/neps_priorband.py b/neps/optimizers/neps_priorband.py new file mode 100644 index 000000000..16ea26e17 --- /dev/null +++ b/neps/optimizers/neps_priorband.py @@ -0,0 +1,205 @@ +"""PriorBand Sampler for NePS Optimizers. +This sampler implements the PriorBand algorithm, which is a sampling strategy +that combines prior knowledge with random sampling to efficiently explore the search +space. It uses a combination of prior sampling, incumbent mutation, and random sampling +based on the fidelity bounds and SH bracket. +""" + +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np + +import neps.space.neps_spaces.sampling +from neps.optimizers.utils import brackets +from neps.space.neps_spaces import neps_space + +if TYPE_CHECKING: + import pandas as pd + + from neps.space.neps_spaces.parameters import PipelineSpace + + +@dataclass +class NePSPriorBandSampler: + """Implement a sampler based on PriorBand.""" + + space: PipelineSpace + """The pipeline space to optimize over.""" + + eta: int + """The eta value to use for the SH bracket.""" + + early_stopping_rate: int + """The early stopping rate to use for the SH bracket.""" + + fid_bounds: tuple[int, int] | tuple[float, float] + """The fidelity bounds.""" + + inc_ratio: float = 0.9 + """The ratio of the incumbent (vs. prior) in the sampling distribution.""" + + def sample_config(self, table: pd.DataFrame, rung: int) -> dict[str, Any]: + """Sample a configuration based on the PriorBand algorithm. + + Args: + table (pd.DataFrame): The table containing the configurations and their + performance. + rung (int): The current rung of the optimization. + + Returns: + dict[str, Any]: A sampled configuration. + """ + rung_to_fid, rung_sizes = brackets.calculate_sh_rungs( + bounds=self.fid_bounds, + eta=self.eta, + early_stopping_rate=self.early_stopping_rate, + ) + max_rung = max(rung_sizes) + + # Below we will follow the "geometric" spacing + w_random = 1 / (1 + self.eta**rung) + w_prior = 1 - w_random + + completed: pd.DataFrame = table[table["perf"].notna()] # type: ignore + + # To see if we activate incumbent sampling, we check: + # 1) We have at least one fully complete run + # 2) We have spent at least one full SH bracket worth of fidelity + # 3) There is at least one rung with eta evaluations to get the top 1/eta configs + completed_rungs = completed.index.get_level_values("rung") + one_complete_run_at_max_rung = (completed_rungs == max_rung).any() + + # For SH bracket cost, we include the fact we can continue runs, + # i.e. resources for rung 2 discounts the cost of evaluating to rung 1, + # only counting the difference in fidelity cost between rung 2 and rung 1. + cost_per_rung = { + i: rung_to_fid[i] - rung_to_fid.get(i - 1, 0) for i in rung_to_fid + } + + cost_of_one_sh_bracket = sum(rung_sizes[r] * cost_per_rung[r] for r in rung_sizes) + current_cost_used = sum(r * cost_per_rung[r] for r in completed_rungs) + spent_one_sh_bracket_worth_of_fidelity = ( + current_cost_used >= cost_of_one_sh_bracket + ) + + # Check that there is at least rung with `eta` evaluations + rung_counts = completed.groupby("rung").size() + any_rung_with_eta_evals = (rung_counts == self.eta).any() + + # If the conditions are not met, we sample from the prior or randomly depending on + # the geometrically distributed prior and uniform weights + if ( + one_complete_run_at_max_rung is False + or spent_one_sh_bracket_worth_of_fidelity is False + or any_rung_with_eta_evals is False + ): + policy = np.random.choice(["prior", "random"], p=[w_prior, w_random]) + match policy: + case "prior": + return self._sample_prior() + case "random": + return self._sample_random() + case _: + raise RuntimeError(f"Unknown policy: {policy}") + + # Otherwise, we now further split the `prior` weight into `(prior, inc)` + + # 1. Select the top `1//eta` percent of configs at the highest rung supporting it + rungs_with_at_least_eta = rung_counts[rung_counts >= self.eta].index # type: ignore + rung_table: pd.DataFrame = completed[ # type: ignore + completed.index.get_level_values("rung") == rungs_with_at_least_eta.max() + ] + + K = len(rung_table) // self.eta + rung_table.nsmallest(K, columns=["perf"])["config"].tolist() + + # 2. Get the global incumbent + inc_config = completed.loc[completed["perf"].idxmin()]["config"] + + # 3. Calculate a ratio score of how likely each of the top K configs are under + # TODO: [lum]: Here I am simply using fixed values. + # Will maybe have to come up with a way to approximate the pdf for the top + # configs. + inc_ratio = self.inc_ratio + prior_ratio = 1 - self.inc_ratio + + # 4. And finally, we distribute the original w_prior according to this ratio + w_inc = w_prior * inc_ratio + w_prior = w_prior * prior_ratio + assert np.isclose(w_prior + w_inc + w_random, 1.0) + + # Now we use these weights to choose which sampling distribution to sample from + policy = np.random.choice( + ["prior", "inc", "random"], + p=[w_prior, w_inc, w_random], + ) + match policy: + case "prior": + return self._sample_prior() + case "random": + return self._sample_random() + case "inc": + assert inc_config is not None + return self._mutate_inc(inc_config) + raise RuntimeError(f"Unknown policy: {policy}") + + def _sample_prior(self) -> dict[str, Any]: + # TODO: [lum] have a CenterSampler as fallback, not Random + _try_always_priors_sampler = ( + neps.space.neps_spaces.sampling.PriorOrFallbackSampler( + fallback_sampler=neps.space.neps_spaces.sampling.RandomSampler( + predefined_samplings={} + ), + always_use_prior=True, + ) + ) + + _environment_values = {} + _fidelity_attrs = self.space.fidelity_attrs + for fidelity_name, fidelity_obj in _fidelity_attrs.items(): + _environment_values[fidelity_name] = fidelity_obj.max_value + + _resolved_pipeline, resolution_context = neps_space.resolve( + pipeline=self.space, + domain_sampler=_try_always_priors_sampler, + environment_values=_environment_values, + ) + + config = neps_space.NepsCompatConverter.to_neps_config(resolution_context) + return dict(**config) + + def _sample_random(self) -> dict[str, Any]: + _environment_values = {} + _fidelity_attrs = self.space.fidelity_attrs + for fidelity_name, fidelity_obj in _fidelity_attrs.items(): + _environment_values[fidelity_name] = fidelity_obj.max_value + + _resolved_pipeline, resolution_context = neps_space.resolve( + pipeline=self.space, + domain_sampler=neps.space.neps_spaces.sampling.RandomSampler( + predefined_samplings={} + ), + environment_values=_environment_values, + ) + + config = neps_space.NepsCompatConverter.to_neps_config(resolution_context) + return dict(**config) + + def _mutate_inc(self, inc_config: dict[str, Any]) -> dict[str, Any]: + data = neps_space.NepsCompatConverter.from_neps_config(config=inc_config) + + _resolved_pipeline, resolution_context = neps_space.resolve( + pipeline=self.space, + domain_sampler=neps.space.neps_spaces.sampling.MutatateUsingCentersSampler( + predefined_samplings=data.predefined_samplings, + n_mutations=max(1, random.randint(1, int(len(inc_config) / 2))), + ), + environment_values=data.environment_values, + ) + + config = neps_space.NepsCompatConverter.to_neps_config(resolution_context) + return dict(**config) diff --git a/neps/optimizers/neps_random_search.py b/neps/optimizers/neps_random_search.py new file mode 100644 index 000000000..db0678659 --- /dev/null +++ b/neps/optimizers/neps_random_search.py @@ -0,0 +1,391 @@ +"""This module implements a simple random search optimizer for a NePS pipeline. +It samples configurations randomly from the pipeline's domain and environment values. +""" + +from __future__ import annotations + +import heapq +import random +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from neps.space.neps_spaces.neps_space import _prepare_sampled_configs, resolve +from neps.space.neps_spaces.parameters import Float, Integer +from neps.space.neps_spaces.sampling import ( + CrossoverByMixingSampler, + CrossoverNotPossibleError, + MutatateUsingCentersSampler, + MutateByForgettingSampler, + PriorOrFallbackSampler, + RandomSampler, +) + +if TYPE_CHECKING: + import neps.state.optimizer as optimizer_state + import neps.state.trial as trial_state + from neps.optimizers import optimizer + from neps.space.neps_spaces.parameters import PipelineSpace + from neps.state.trial import Trial + + +@dataclass +class NePSRandomSearch: + """A simple random search optimizer for a NePS pipeline. + It samples configurations randomly from the pipeline's domain and environment values. + + Args: + pipeline: The pipeline to optimize, which should be a Pipeline object. + + Raises: + ValueError: If the pipeline is not a Pipeline object. + """ + + def __init__( + self, + pipeline: PipelineSpace, + use_priors: bool = False, # noqa: FBT001, FBT002 + ignore_fidelity: bool | Literal["highest fidelity"] = False, # noqa: FBT002 + ): + """Initialize the RandomSearch optimizer with a pipeline. + + Args: + pipeline: The pipeline to optimize, which should be a Pipeline object. + + Raises: + ValueError: If the pipeline is not a Pipeline object. + """ + self._pipeline = pipeline + + self._environment_values = {} + fidelity_attrs = self._pipeline.fidelity_attrs + for fidelity_name, fidelity_obj in fidelity_attrs.items(): + if ignore_fidelity == "highest fidelity": + self._environment_values[fidelity_name] = fidelity_obj.max_value + elif not ignore_fidelity: + raise ValueError( + "RandomSearch does not support fidelities by default. Consider using" + " a different optimizer or setting `ignore_fidelity=True` or `highest" + " fidelity`." + ) + # Sample randomly from the fidelity bounds. + elif isinstance(fidelity_obj._domain, Integer): + assert isinstance(fidelity_obj.min_value, int) + assert isinstance(fidelity_obj.max_value, int) + self._environment_values[fidelity_name] = random.randint( + fidelity_obj.min_value, fidelity_obj.max_value + ) + elif isinstance(fidelity_obj._domain, Float): + self._environment_values[fidelity_name] = random.uniform( + fidelity_obj.min_value, fidelity_obj.max_value + ) + + self._random_sampler = RandomSampler(predefined_samplings={}) + self.use_prior = use_priors + self._prior_sampler = PriorOrFallbackSampler( + fallback_sampler=self._random_sampler + ) + + def __call__( + self, + trials: Mapping[str, trial_state.Trial], + budget_info: optimizer_state.BudgetInfo | None, + n: int | None = None, + ) -> optimizer.SampledConfig | list[optimizer.SampledConfig]: + """Sample configurations randomly from the pipeline's domain and environment + values. + + Args: + trials: A mapping of trial IDs to Trial objects, representing previous + trials. + budget_info: The budget information for the optimization process. + n: The number of configurations to sample. If None, a single configuration + will be sampled. + + Returns: + A SampledConfig object or a list of SampledConfig objects, depending + on the value of n. + + Raises: + ValueError: If the pipeline is not a Pipeline object or if the trials are + not a valid mapping of trial IDs to Trial objects. + """ + n_prev_trials = len(trials) + n_requested = 1 if n is None else n + return_single = n is None + + if self.use_prior: + chosen_pipelines = [ + resolve( + pipeline=self._pipeline, + domain_sampler=self._prior_sampler, + environment_values=self._environment_values, + ) + for _ in range(n_requested) + ] + else: + chosen_pipelines = [ + resolve( + pipeline=self._pipeline, + domain_sampler=self._random_sampler, + environment_values=self._environment_values, + ) + for _ in range(n_requested) + ] + + return _prepare_sampled_configs(chosen_pipelines, n_prev_trials, return_single) + + +@dataclass +class NePSComplexRandomSearch: + """A complex random search optimizer for a NePS pipeline. + It samples configurations randomly from the pipeline's domain and environment values, + and also performs mutations and crossovers based on previous successful trials. + + Args: + pipeline: The pipeline to optimize, which should be a Pipeline object. + + Raises: + ValueError: If the pipeline is not a Pipeline object. + """ + + def __init__( + self, + pipeline: PipelineSpace, + ignore_fidelity: bool | Literal["highest fidelity"] = False, # noqa: FBT002 + ): + """Initialize the ComplexRandomSearch optimizer with a pipeline. + + Args: + pipeline: The pipeline to optimize, which should be a Pipeline object. + + Raises: + ValueError: If the pipeline is not a Pipeline object. + """ + self._pipeline = pipeline + + self._environment_values = {} + fidelity_attrs = self._pipeline.fidelity_attrs + for fidelity_name, fidelity_obj in fidelity_attrs.items(): + if ignore_fidelity == "highest fidelity": + self._environment_values[fidelity_name] = fidelity_obj.max_value + elif not ignore_fidelity: + raise ValueError( + "ComplexRandomSearch does not support fidelities by default. Consider" + " using a different optimizer or setting `ignore_fidelity=True` or" + " `highest fidelity`." + ) + # Sample randomly from the fidelity bounds. + elif isinstance(fidelity_obj._domain, Integer): + assert isinstance(fidelity_obj.min_value, int) + assert isinstance(fidelity_obj.max_value, int) + self._environment_values[fidelity_name] = random.randint( + fidelity_obj.min_value, fidelity_obj.max_value + ) + elif isinstance(fidelity_obj._domain, Float): + self._environment_values[fidelity_name] = random.uniform( + fidelity_obj.min_value, fidelity_obj.max_value + ) + + self._random_sampler = RandomSampler( + predefined_samplings={}, + ) + self._try_always_priors_sampler = PriorOrFallbackSampler( + fallback_sampler=self._random_sampler, + always_use_prior=True, + ) + self._sometimes_priors_sampler = PriorOrFallbackSampler( + fallback_sampler=self._random_sampler + ) + self._n_top_trials = 5 + + def __call__( + self, + trials: Mapping[str, trial_state.Trial], + budget_info: optimizer_state.BudgetInfo | None, + n: int | None = None, + ) -> optimizer.SampledConfig | list[optimizer.SampledConfig]: + """Sample configurations randomly from the pipeline's domain and environment + values, and also perform mutations and crossovers based on previous successful + trials. + + Args: + trials: A mapping of trial IDs to Trial objects, representing previous + trials. + budget_info: The budget information for the optimization process. + n: The number of configurations to sample. If None, a single configuration + will be sampled. + + Returns: + A SampledConfig object or a list of SampledConfig objects, depending + on the value of n. + + Raises: + ValueError: If the pipeline is not a Pipeline object or if the trials are + not a valid mapping of trial IDs to Trial objects. + """ + n_prev_trials = len(trials) + n_requested = 1 if n is None else n + return_single = n is None + + random_pipelines = [ + resolve( + pipeline=self._pipeline, + domain_sampler=self._random_sampler, + environment_values=self._environment_values, + ) + for _ in range(n_requested * 5) + ] + sometimes_priors_pipelines = [ + resolve( + pipeline=self._pipeline, + domain_sampler=self._sometimes_priors_sampler, + environment_values=self._environment_values, + ) + for _ in range(n_requested * 5) + ] + + mutated_incumbents = [] + crossed_over_incumbents = [] + + successful_trials: list[Trial] = list( + filter( + lambda trial: ( + trial.report.reported_as == trial.State.SUCCESS + if trial.report is not None + else False + ), + trials.values(), + ) + ) + if len(successful_trials) > 0: + self._n_top_trials = 5 + top_trials = heapq.nsmallest( + self._n_top_trials, + successful_trials, + key=lambda trial: ( + float(trial.report.objective_to_minimize) + if trial.report + and isinstance(trial.report.objective_to_minimize, float) + else float("inf") + ), + ) # Will have up to `self._n_top_trials` items. + + # Do some mutations. + for top_trial in top_trials: + top_trial_config = top_trial.config + + # Mutate by resampling around some values of the original config. + mutated_incumbents += [ + resolve( + pipeline=self._pipeline, + domain_sampler=MutatateUsingCentersSampler( + predefined_samplings=top_trial_config, + n_mutations=1, + ), + environment_values=self._environment_values, + ) + for _ in range(n_requested * 5) + ] + mutated_incumbents += [ + resolve( + pipeline=self._pipeline, + domain_sampler=MutatateUsingCentersSampler( + predefined_samplings=top_trial_config, + n_mutations=max( + 1, random.randint(1, int(len(top_trial_config) / 2)) + ), + ), + environment_values=self._environment_values, + ) + for _ in range(n_requested * 5) + ] + + # Mutate by completely forgetting some values of the original config. + mutated_incumbents += [ + resolve( + pipeline=self._pipeline, + domain_sampler=MutateByForgettingSampler( + predefined_samplings=top_trial_config, + n_forgets=1, + ), + environment_values=self._environment_values, + ) + for _ in range(n_requested * 5) + ] + mutated_incumbents += [ + resolve( + pipeline=self._pipeline, + domain_sampler=MutateByForgettingSampler( + predefined_samplings=top_trial_config, + n_forgets=max( + 1, random.randint(1, int(len(top_trial_config) / 2)) + ), + ), + environment_values=self._environment_values, + ) + for _ in range(n_requested * 5) + ] + + # Do some crossovers. + if len(top_trials) > 1: + for _ in range(n_requested * 3): + trial_1, trial_2 = random.sample(top_trials, k=2) + + try: + crossover_sampler = CrossoverByMixingSampler( + predefined_samplings_1=trial_1.config, + predefined_samplings_2=trial_2.config, + prefer_first_probability=0.5, + ) + except CrossoverNotPossibleError: + # A crossover was not possible for them. Do nothing. + pass + else: + crossed_over_incumbents.append( + resolve( + pipeline=self._pipeline, + domain_sampler=crossover_sampler, + environment_values=self._environment_values, + ), + ) + + try: + crossover_sampler = CrossoverByMixingSampler( + predefined_samplings_1=trial_2.config, + predefined_samplings_2=trial_1.config, + prefer_first_probability=0.5, + ) + except CrossoverNotPossibleError: + # A crossover was not possible for them. Do nothing. + pass + else: + crossed_over_incumbents.append( + resolve( + pipeline=self._pipeline, + domain_sampler=crossover_sampler, + environment_values=self._environment_values, + ), + ) + + all_sampled_pipelines = [ + *random_pipelines, + *sometimes_priors_pipelines, + *mutated_incumbents, + *crossed_over_incumbents, + ] + + # Here we can have a model which picks from all the sampled pipelines. + # Currently, we just pick randomly from them. + chosen_pipelines = random.sample(all_sampled_pipelines, k=n_requested) + + if n_prev_trials == 0: + # In this case, always include the prior pipeline. + prior_pipeline = resolve( + pipeline=self._pipeline, + domain_sampler=self._try_always_priors_sampler, + environment_values=self._environment_values, + ) + chosen_pipelines[0] = prior_pipeline + + return _prepare_sampled_configs(chosen_pipelines, n_prev_trials, return_single) diff --git a/neps/optimizers/priorband.py b/neps/optimizers/priorband.py index 9d6d23e4b..6d01581e9 100644 --- a/neps/optimizers/priorband.py +++ b/neps/optimizers/priorband.py @@ -52,7 +52,7 @@ def sample_config(self, table: pd.DataFrame, rung: int) -> dict[str, Any]: Args: table: The table of all the trials that have been run. - rung_to_sample_for: The rung to sample for. + rung: The rung to sample for. Returns: The sampled configuration. diff --git a/neps/optimizers/random_search.py b/neps/optimizers/random_search.py index 0de6d28a3..e1b484b37 100644 --- a/neps/optimizers/random_search.py +++ b/neps/optimizers/random_search.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Any from neps.optimizers.optimizer import ImportedConfig, SampledConfig +from neps.space.neps_spaces.neps_space import convert_neps_to_classic_search_space +from neps.space.neps_spaces.parameters import PipelineSpace if TYPE_CHECKING: from neps.sampling import Sampler @@ -17,7 +19,7 @@ class RandomSearch: """A simple random search optimizer.""" - space: SearchSpace + space: SearchSpace | PipelineSpace encoder: ConfigEncoder sampler: Sampler @@ -27,6 +29,15 @@ def __call__( budget_info: BudgetInfo | None, n: int | None = None, ) -> SampledConfig | list[SampledConfig]: + if isinstance(self.space, PipelineSpace): + converted_space = convert_neps_to_classic_search_space(self.space) + if converted_space is not None: + self.space = converted_space + else: + raise ValueError( + "This optimizer only supports HPO search spaces, please use a NePS" + " space-compatible optimizer." + ) n_trials = len(trials) _n = 1 if n is None else n configs = self.sampler.sample(_n, to=self.encoder.domains) diff --git a/neps/optimizers/utils/grid.py b/neps/optimizers/utils/grid.py index 720dd7713..e3b95f98a 100644 --- a/neps/optimizers/utils/grid.py +++ b/neps/optimizers/utils/grid.py @@ -1,28 +1,38 @@ from __future__ import annotations from itertools import product -from typing import Any +from typing import Any, Literal import torch -from neps.space import Categorical, Constant, Domain, Float, Integer, SearchSpace +from neps import Categorical, Fidelity, Float, Integer, PipelineSpace +from neps.space import ( + Domain, + HPOCategorical, + HPOConstant, + HPOFloat, + HPOInteger, + SearchSpace, +) +from neps.space.neps_spaces import neps_space +from neps.space.neps_spaces.sampling import RandomSampler -def make_grid( - space: SearchSpace, +def make_grid( # noqa: PLR0912, PLR0915, C901 + space: SearchSpace | PipelineSpace, *, size_per_numerical_hp: int = 10, - ignore_fidelity: bool = True, + ignore_fidelity: bool | Literal["highest fidelity"] = False, ) -> list[dict[str, Any]]: """Get a grid of configurations from the search space. - For [`Float`][neps.space.Float] and [`Integer`][neps.space.Integer] + For [`Float`][neps.space.HPOFloat] and [`Integer`][neps.space.HPOInteger] the parameter `size_per_numerical_hp=` is used to determine a grid. - For [`Categorical`][neps.space.Categorical] + For [`Categorical`][neps.space.HPOCategorical] hyperparameters, we include all the choices in the grid. - For [`Constant`][neps.space.Constant] hyperparameters, + For [`Constant`][neps.space.HPOConstant] hyperparameters, we include the constant value in the grid. Args: @@ -32,29 +42,102 @@ def make_grid( A list of configurations from the search space. """ param_ranges: dict[str, list[Any]] = {} - for name, hp in space.items(): - match hp: - case Categorical(): - param_ranges[name] = list(hp.choices) - case Constant(): - param_ranges[name] = [hp.value] - case Integer() | Float(): - if hp.is_fidelity and ignore_fidelity: - param_ranges[name] = [hp.upper] - continue + if isinstance(space, SearchSpace): + for name, hp in space.items(): + match hp: + case HPOCategorical(): + param_ranges[name] = list(hp.choices) + case HPOConstant(): + param_ranges[name] = [hp.value] + case HPOInteger() | HPOFloat(): + if hp.is_fidelity: + match ignore_fidelity: + case "highest fidelity": + param_ranges[name] = [hp.upper] + continue + case True: + param_ranges[name] = [hp.lower, hp.upper] + case False: + raise ValueError( + "Grid search does not support fidelity " + "natively. Please use the" + "ignore_fidelity parameter." + ) + if hp.domain.cardinality is None: + steps = size_per_numerical_hp + else: + steps = min(size_per_numerical_hp, hp.domain.cardinality) - if hp.domain.cardinality is None: - steps = size_per_numerical_hp + xs = torch.linspace(0, 1, steps=steps) + numeric_values = hp.domain.cast(xs, frm=Domain.unit_float()) + uniq_values = torch.unique(numeric_values).tolist() + param_ranges[name] = uniq_values + case _: + raise NotImplementedError(f"Unknown Parameter type: {type(hp)}\n{hp}") + keys = list(space.keys()) + values = product(*param_ranges.values()) + return [dict(zip(keys, p, strict=False)) for p in values] + if isinstance(space, PipelineSpace): + fid_ranges: dict[str, list[float]] = {} + for name, hp in space.get_attrs().items(): + if isinstance(hp, Categorical): + if isinstance(hp.choices, tuple): # type: ignore[unreachable] + param_ranges[name] = list(range(len(hp.choices))) else: - steps = min(size_per_numerical_hp, hp.domain.cardinality) - + raise NotImplementedError( + "Grid search only supports categorical choices as tuples." + ) + elif isinstance(hp, Fidelity): + if ignore_fidelity == "highest fidelity": # type: ignore[unreachable] + fid_ranges[name] = [hp.max_value] + continue + if ignore_fidelity is True: + fid_ranges[name] = [hp.min_value, hp.max_value] + continue + raise ValueError( + "Grid search does not support fidelity natively." + " Please use the ignore_fidelity parameter." + ) + elif isinstance(hp, Integer | Float): + steps = size_per_numerical_hp # type: ignore[unreachable] xs = torch.linspace(0, 1, steps=steps) - numeric_values = hp.domain.cast(xs, frm=Domain.unit_float()) + numeric_values = xs * (hp.max_value - hp.min_value) + hp.min_value + if isinstance(hp, Integer): + numeric_values = torch.round(numeric_values) uniq_values = torch.unique(numeric_values).tolist() param_ranges[name] = uniq_values - case _: - raise NotImplementedError(f"Unknown Parameter type: {type(hp)}\n{hp}") - values = product(*param_ranges.values()) - keys = list(space.keys()) + else: + raise NotImplementedError( + f"Parameter type: {type(hp)}\n{hp} not supported yet in GridSearch" + ) + keys = list(param_ranges.keys()) + values = product(*param_ranges.values()) + config_dicts = [dict(zip(keys, p, strict=False)) for p in values] + keys_fid = list(fid_ranges.keys()) + values_fid = product(*fid_ranges.values()) + fid_dicts = [dict(zip(keys_fid, p, strict=False)) for p in values_fid] + configs = [] + random_config = neps_space.NepsCompatConverter.to_neps_config( + neps_space.resolve( + pipeline=space, + domain_sampler=RandomSampler(predefined_samplings={}), + environment_values=fid_dicts[0], + )[1] + ) + + for config_dict in config_dicts: + for fid_dict in fid_dicts: + new_config = {} + for param in random_config: + for key in config_dict: + if key in param: + new_config[param] = config_dict[key] + for key in fid_dict: + if key in param: + new_config[param] = fid_dict[key] + configs.append(new_config) + return configs - return [dict(zip(keys, p, strict=False)) for p in values] + raise TypeError( + f"Unsupported space type: {type(space)}" + ) # More informative than None diff --git a/neps/optimizers/utils/initial_design.py b/neps/optimizers/utils/initial_design.py index 615a5a257..81a895489 100644 --- a/neps/optimizers/utils/initial_design.py +++ b/neps/optimizers/utils/initial_design.py @@ -24,7 +24,6 @@ def make_initial_design( """Generate the initial design of the optimization process. Args: - space: The search space to use. encoder: The encoder to use for encoding/decoding configurations. sampler: The sampler to use for the initial design. diff --git a/neps/runtime.py b/neps/runtime.py index 988c8f500..091a050ea 100644 --- a/neps/runtime.py +++ b/neps/runtime.py @@ -35,6 +35,7 @@ WorkerFailedToGetPendingTrialsError, WorkerRaiseError, ) +from neps.space.neps_spaces.neps_space import NepsCompatConverter, PipelineSpace from neps.state import ( BudgetInfo, DefaultReportValues, @@ -48,7 +49,7 @@ WorkerSettings, evaluate_trial, ) -from neps.status.status import _initiate_summary_csv, status +from neps.status.status import _build_trace_texts, _initiate_summary_csv, status from neps.utils.common import gc_disabled if TYPE_CHECKING: @@ -324,13 +325,15 @@ def _check_shared_error_stopping_criterion(self) -> str | Literal[False]: return False - def _check_global_stopping_criterion( + def _check_global_stopping_criterion( # noqa: C901 self, trials: Mapping[str, Trial], - ) -> str | Literal[False]: + ) -> tuple[str | Literal[False], dict[str, float | int]]: + return_dict: dict[str, float | int] = {} + return_string: str | Literal[False] = False if self.settings.evaluations_to_spend is not None: if self.settings.include_in_progress_evaluations_towards_maximum: - count = sum( + count_evals = sum( 1 for _, trial in trials.items() if trial.metadata.state @@ -338,10 +341,12 @@ def _check_global_stopping_criterion( ) else: # This indicates they have completed. - count = sum(1 for _, trial in trials.items() if trial.report is not None) - - if count >= self.settings.evaluations_to_spend: - return ( + count_evals = sum( + 1 for _, trial in trials.items() if trial.report is not None + ) + return_dict["cumulative_evaluations"] = count_evals + if count_evals >= self.settings.evaluations_to_spend: + return_string = ( "The total number of evaluations has reached the maximum allowed of" f" `{self.settings.evaluations_to_spend=}`." " To allow more evaluations, increase this value or use a different" @@ -351,14 +356,21 @@ def _check_global_stopping_criterion( if self.settings.fidelities_to_spend is not None and hasattr( self.optimizer, "space" ): - fidelity_name = next(iter(self.optimizer.space.fidelities.keys())) - count = sum( + if not isinstance(self.optimizer.space, PipelineSpace): + fidelity_name = next(iter(self.optimizer.space.fidelities.keys())) + else: + fidelity_name = next(iter(self.optimizer.space.fidelity_attrs.keys())) + fidelity_name = ( + f"{NepsCompatConverter._ENVIRONMENT_PREFIX}{fidelity_name}" + ) + count_fidelities = sum( trial.config[fidelity_name] for _, trial in trials.items() if trial.report is not None and trial.config[fidelity_name] is not None ) - if count >= self.settings.fidelities_to_spend: - return ( + return_dict["cumulative_fidelities"] = count_fidelities + if count_fidelities >= self.settings.fidelities_to_spend: + return_string = ( "The total number of fidelity evaluations has reached the maximum" f" allowed of `{self.settings.fidelities_to_spend=}`." " To allow more evaluations, increase this value or use a different" @@ -371,8 +383,9 @@ def _check_global_stopping_criterion( for _, trial in trials.items() if trial.report is not None and trial.report.cost is not None ) + return_dict["cumulative_cost"] = cost if cost >= self.settings.cost_to_spend: - return ( + return_string = ( f"The maximum cost `{self.settings.cost_to_spend=}` has been" " reached by all of the evaluated trials. To allow more evaluations," " increase this value or use a different stopping criterion." @@ -385,15 +398,16 @@ def _check_global_stopping_criterion( if trial.report is not None if trial.report.evaluation_duration is not None ) + return_dict["cumulative_time"] = time_spent if time_spent >= self.settings.max_evaluation_time_total_seconds: - return ( + return_string = ( "The maximum evaluation time of" f" `{self.settings.max_evaluation_time_total_seconds=}` has been" " reached. To allow more evaluations, increase this value or use" " a different stopping criterion." ) - return False + return (return_string, return_dict) @property def _requires_global_stopping_criterion(self) -> bool: @@ -404,7 +418,7 @@ def _requires_global_stopping_criterion(self) -> bool: or self.settings.max_evaluation_time_total_seconds is not None ) - def _get_next_trial(self) -> Trial | Literal["break"]: + def _get_next_trial(self) -> Trial | Literal["break"]: # noqa: PLR0915, C901 # If there are no global stopping criterion, we can no just return early. with self.state._optimizer_lock.lock(worker_id=self.worker_id): # NOTE: It's important to release the trial lock before sampling @@ -421,8 +435,41 @@ def _get_next_trial(self) -> Trial | Literal["break"]: trials = self.state._trial_repo.latest() if self._requires_global_stopping_criterion: - should_stop = self._check_global_stopping_criterion(trials) + should_stop, stop_dict = self._check_global_stopping_criterion(trials) if should_stop is not False: + _trace_lock = FileLock(".trace.lock") + _trace_lock_path = Path(str(_trace_lock.lock_file)) + _trace_lock_path.touch(exist_ok=True) + + if self.settings.write_summary_to_disk: + # Update the best_config.txt to include the final cumulative + # metrics + main_dir = Path(self.state.path) + summary_dir = main_dir / "summary" + improvement_trace_path = ( + summary_dir / "best_config_trajectory.txt" + ) + best_config_path = summary_dir / "best_config.txt" + + with _trace_lock: + trace_text, best_config_text = _build_trace_texts( + self.state.all_best_configs + ) + + # Add final cumulative metrics to the best config text + best_config_text += "\n" + best_config_text += "-" * 80 + best_config_text += ( + "\nFinal cumulative metrics (Assuming completed run):" + ) + for metric, value in stop_dict.items(): + best_config_text += f"\n{metric}: {value}" + + with improvement_trace_path.open(mode="w") as f: + f.write(trace_text) + + with best_config_path.open(mode="w") as f: + f.write(best_config_text) logger.info(should_stop) return "break" @@ -521,6 +568,15 @@ def run(self) -> None: # noqa: C901, PLR0912, PLR0915 _set_workers_neps_state(self.state) main_dir = Path(self.state.path) + summary_dir = main_dir / "summary" + summary_dir.mkdir(parents=True, exist_ok=True) + improvement_trace_path = summary_dir / "best_config_trajectory.txt" + improvement_trace_path.touch(exist_ok=True) + best_config_path = summary_dir / "best_config.txt" + best_config_path.touch(exist_ok=True) + _trace_lock = FileLock(".trace.lock") + _trace_lock_path = Path(str(_trace_lock.lock_file)) + _trace_lock_path.touch(exist_ok=True) if self.settings.write_summary_to_disk: full_df_path, short_path, csv_locker = _initiate_summary_csv(main_dir) @@ -530,17 +586,6 @@ def run(self) -> None: # noqa: C901, PLR0912, PLR0915 full_df_path.touch(exist_ok=True) short_path.touch(exist_ok=True) - summary_dir = main_dir / "summary" - summary_dir.mkdir(parents=True, exist_ok=True) - - improvement_trace_path = summary_dir / "best_config_trajectory.txt" - improvement_trace_path.touch(exist_ok=True) - best_config_path = summary_dir / "best_config.txt" - best_config_path.touch(exist_ok=True) - _trace_lock = FileLock(".trace.lock") - _trace_lock_path = Path(str(_trace_lock.lock_file)) - _trace_lock_path.touch(exist_ok=True) - logger.info( "Summary files can be found in the “summary” folder inside" "the root directory: %s", @@ -549,13 +594,14 @@ def run(self) -> None: # noqa: C901, PLR0912, PLR0915 previous_trials = self.state.lock_and_read_trials() if len(previous_trials): - load_incumbent_trace( + self.load_incumbent_trace( previous_trials, _trace_lock, self.state, self.settings, improvement_trace_path, best_config_path, + self.optimizer, ) _best_score_so_far = float("inf") @@ -722,33 +768,30 @@ def run(self) -> None: # noqa: C901, PLR0912, PLR0915 if self.settings.write_summary_to_disk: # Store in memory for later file re-writing - self.state.all_best_configs.append( - { - "score": self.state.new_score, - "trial_id": evaluated_trial.id, - "config": evaluated_trial.config, - } - ) - - # Build trace text and best config text - trace_text = ( - "Best configs and their objectives across evaluations:\n" - + "-" * 80 - + "\n" - ) - for best in self.state.all_best_configs: - trace_text += ( - f"Objective to minimize: {best['score']}\n" - f"Config ID: {best['trial_id']}\n" - f"Config: {best['config']}\n" + "-" * 80 + "\n" - ) - - best_config = self.state.all_best_configs[-1] # Latest best - best_config_text = ( - f"# Best config:" - f"\n\n Config ID: {best_config['trial_id']}" - f"\n Objective to minimize: {best_config['score']}" - f"\n Config: {best_config['config']}" + global_stopping_criterion = self._check_global_stopping_criterion( + self.state._trial_repo.latest() + )[1] + + config_dict = { + "score": self.state.new_score, + "trial_id": evaluated_trial.id, + "config": evaluated_trial.config, + } + if report.cost is not None: + config_dict["cost"] = report.cost + for metric in ( + "cumulative_evaluations", + "cumulative_fidelities", + "cumulative_cost", + "cumulative_time", + ): + if metric in global_stopping_criterion: + config_dict[metric] = global_stopping_criterion[metric] + self.state.all_best_configs.append(config_dict) + + # Build trace text and best config text using shared function + trace_text, best_config_text = _build_trace_texts( + self.state.all_best_configs ) # Write files from scratch @@ -772,60 +815,111 @@ def run(self) -> None: # noqa: C901, PLR0912, PLR0915 "Learning Curve %s: %s", evaluated_trial.id, report.learning_curve ) + def load_incumbent_trace( # noqa: C901, PLR0912 + self, + previous_trials: dict[str, Trial], + _trace_lock: FileLock, + state: NePSState, + settings: WorkerSettings, + improvement_trace_path: Path, + best_config_path: Path, + optimizer: AskFunction, + ) -> None: + """Load the incumbent trace from previous trials and update the state. + This function also computes cumulative metrics and updates the best + configurations. + + Args: + previous_trials (dict): A dictionary of previous trials. + _trace_lock (FileLock): A file lock to ensure thread-safe writing. + state (NePSState): The current NePS state. + settings (WorkerSettings): The worker settings. + improvement_trace_path (Path): Path to the improvement trace file. + best_config_path (Path): Path to the best configuration file. + optimizer (AskFunction): The optimizer used for sampling configurations. + """ + _best_score_so_far = float("inf") -def load_incumbent_trace( # noqa: D103 - previous_trials: dict[str, Trial], - _trace_lock: FileLock, - state: NePSState, - settings: WorkerSettings, # noqa: ARG001 - improvement_trace_path: Path, - best_config_path: Path, -) -> None: - _best_score_so_far = float("inf") + metrics = { + "cumulative_evaluations": 0, + "cumulative_fidelities": 0.0, + "cumulative_cost": 0.0, + "cumulative_time": 0.0, + } + for evaluated_trial in previous_trials.values(): + if ( + evaluated_trial.report is not None + and evaluated_trial.report.objective_to_minimize is not None + ): + metrics["cumulative_evaluations"] += 1 + if ( + settings.cost_to_spend is not None + and evaluated_trial.report.cost is not None + ): + metrics["cumulative_cost"] += evaluated_trial.report.cost + if ( + ( + settings.max_evaluation_time_total_seconds is not None + or evaluated_trial.metadata.evaluation_duration is not None + or settings.max_evaluation_time_total_seconds is not None + ) + and evaluated_trial.metadata.time_started is not None + and evaluated_trial.metadata.time_end is not None + ): + metrics["cumulative_time"] += ( + evaluated_trial.metadata.time_end + - evaluated_trial.metadata.time_started + ) - for evaluated_trial in previous_trials.values(): - if ( - evaluated_trial.report is not None - and evaluated_trial.report.objective_to_minimize is not None - ): - state.new_score = evaluated_trial.report.objective_to_minimize - if state.new_score is not None and state.new_score < _best_score_so_far: - _best_score_so_far = state.new_score - state.all_best_configs.append( - { + if hasattr(optimizer, "space"): + fidelity_name = "" + if not isinstance(optimizer.space, PipelineSpace): + if optimizer.space.fidelities: + fidelity_name = next(iter(optimizer.space.fidelities.keys())) + elif optimizer.space.fidelity_attrs: + fidelity_name = next(iter(optimizer.space.fidelity_attrs.keys())) + fidelity_name = ( + f"{NepsCompatConverter._ENVIRONMENT_PREFIX}{fidelity_name}" + ) + if ( + fidelity_name in evaluated_trial.config + and evaluated_trial.config[fidelity_name] is not None + ): + metrics["cumulative_fidelities"] += evaluated_trial.config[ + fidelity_name + ] + + state.new_score = evaluated_trial.report.objective_to_minimize + if state.new_score is not None and state.new_score < _best_score_so_far: + _best_score_so_far = state.new_score + config_dict = { "score": state.new_score, "trial_id": evaluated_trial.metadata.id, "config": evaluated_trial.config, } - ) - trace_text = ( - "Best configs and their objectives across evaluations:\n" + "-" * 80 + "\n" - ) - for best in state.all_best_configs: - trace_text += ( - f"Objective to minimize: {best['score']}\n" - f"Config ID: {best['trial_id']}\n" - f"Config: {best['config']}\n" + "-" * 80 + "\n" - ) + # Add cost if available + if evaluated_trial.report.cost is not None: + config_dict["cost"] = evaluated_trial.report.cost + state.all_best_configs.append(config_dict) - best_config_text = "" - if state.all_best_configs: - best_config = state.all_best_configs[-1] - best_config_text = ( - f"# Best config:" - f"\n\n Config ID: {best_config['trial_id']}" - f"\n Objective to minimize: {best_config['score']}" - f"\n Config: {best_config['config']}" - ) - else: - best_config = None + for metric in ( + "cumulative_evaluations", + "cumulative_fidelities", + "cumulative_cost", + "cumulative_time", + ): + if metrics[metric] > 0: + config_dict[metric] = metrics[metric] - with _trace_lock: - with improvement_trace_path.open(mode="w") as f: - f.write(trace_text) - with best_config_path.open(mode="w") as f: - f.write(best_config_text) + # Use the shared function to build trace texts + trace_text, best_config_text = _build_trace_texts(state.all_best_configs) + + with _trace_lock: + with improvement_trace_path.open(mode="w") as f: + f.write(trace_text) + with best_config_path.open(mode="w") as f: + f.write(best_config_text) def _save_results( @@ -859,9 +953,11 @@ def _save_results( raise RuntimeError(f"Trial '{trial_id}' not found in '{root_directory}'") report = trial.set_complete( - report_as=Trial.State.SUCCESS.value - if result.exception is None - else Trial.State.CRASHED.value, + report_as=( + Trial.State.SUCCESS.value + if result.exception is None + else Trial.State.CRASHED.value + ), objective_to_minimize=result.objective_to_minimize, cost=result.cost, learning_curve=result.learning_curve, diff --git a/neps/sampling/priors.py b/neps/sampling/priors.py index dfff8d612..a8ee364ed 100644 --- a/neps/sampling/priors.py +++ b/neps/sampling/priors.py @@ -23,11 +23,13 @@ TruncatedNormal, ) from neps.sampling.samplers import Sampler -from neps.space import Categorical, ConfigEncoder, Domain, Float, Integer +from neps.space import ConfigEncoder, Domain, HPOCategorical, HPOFloat, HPOInteger if TYPE_CHECKING: from torch.distributions import Distribution +PRIOR_CONFIDENCE_MAPPING = {"low": 0.25, "medium": 0.5, "high": 0.75} + class Prior(Sampler): """A protocol for priors over search spaces. @@ -120,7 +122,7 @@ def uniform(cls, ncols: int) -> Uniform: @classmethod def from_parameters( cls, - parameters: Mapping[str, Categorical | Float | Integer], + parameters: Mapping[str, HPOCategorical | HPOFloat | HPOInteger], *, center_values: Mapping[str, Any] | None = None, confidence_values: Mapping[str, float] | None = None, @@ -144,7 +146,7 @@ def from_parameters( Returns: The prior distribution """ - _mapping = {"low": 0.25, "medium": 0.5, "high": 0.75} + _mapping = PRIOR_CONFIDENCE_MAPPING center_values = center_values or {} confidence_values = confidence_values or {} @@ -160,7 +162,9 @@ def from_parameters( continue confidence_score = confidence_values.get(name, _mapping[hp.prior_confidence]) - center = hp.choices.index(default) if isinstance(hp, Categorical) else default + center = ( + hp.choices.index(default) if isinstance(hp, HPOCategorical) else default + ) centers.append((center, confidence_score)) return Prior.from_domains_and_centers(domains=domains, centers=centers) @@ -356,7 +360,7 @@ def log_pdf( if x.shape[-1] != len(self.distributions): raise ValueError( - f"Got a tensor `x` whose last dimesion (the hyperparameter dimension)" + "Got a tensor `x` whose last dimesion (the hyperparameter dimension)" f" is of length {x.shape[-1]=} but" f" the CenteredPrior called has {len(self.distributions)=}" " distributions to use for calculating the `log_pdf`. Perhaps" diff --git a/neps/space/__init__.py b/neps/space/__init__.py index f2bbc55ca..477596241 100644 --- a/neps/space/__init__.py +++ b/neps/space/__init__.py @@ -1,15 +1,21 @@ from neps.space.domain import Domain from neps.space.encoding import ConfigEncoder -from neps.space.parameters import Categorical, Constant, Float, Integer, Parameter +from neps.space.parameters import ( + HPOCategorical, + HPOConstant, + HPOFloat, + HPOInteger, + Parameter, +) from neps.space.search_space import SearchSpace __all__ = [ - "Categorical", "ConfigEncoder", - "Constant", "Domain", - "Float", - "Integer", + "HPOCategorical", + "HPOConstant", + "HPOFloat", + "HPOInteger", "Parameter", "SearchSpace", ] diff --git a/neps/space/encoding.py b/neps/space/encoding.py index d58c63dc3..b0a8527f9 100644 --- a/neps/space/encoding.py +++ b/neps/space/encoding.py @@ -15,7 +15,7 @@ import torch from neps.space.domain import Domain -from neps.space.parameters import Categorical, Float, Integer, Parameter +from neps.space.parameters import HPOCategorical, HPOFloat, HPOInteger, Parameter V = TypeVar("V", int, float) @@ -486,9 +486,9 @@ def from_parameters( continue match hp: - case Float() | Integer(): + case HPOFloat() | HPOInteger(): transformers[name] = MinMaxNormalizer(hp.domain) # type: ignore - case Categorical(): + case HPOCategorical(): transformers[name] = CategoricalToIntegerTransformer(hp.choices) case _: raise ValueError(f"Unsupported parameter type: {type(hp)}.") diff --git a/neps/space/neps_spaces/__init__.py b/neps/space/neps_spaces/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/neps/space/neps_spaces/config_string.py b/neps/space/neps_spaces/config_string.py new file mode 100644 index 000000000..395820d78 --- /dev/null +++ b/neps/space/neps_spaces/config_string.py @@ -0,0 +1,326 @@ +"""This module provides functionality to unwrap and wrap configuration strings +used in NePS spaces. It defines the `UnwrappedConfigStringPart` data class +to represent parts of the unwrapped configuration string and provides +functions to unwrap a configuration string into these parts and to wrap +unwrapped parts back into a configuration string. +""" + +from __future__ import annotations + +import dataclasses +import functools +from collections.abc import Callable +from typing import Any + + +@dataclasses.dataclass(frozen=True) +class UnwrappedConfigStringPart: + """A data class representing a part of an unwrapped configuration string. + + Args: + level: The hierarchy level of this part in the configuration string. + opening_index: The index of the opening parenthesis in the original string. + operator: The operator of this part, which is the first word in the + parenthesis. + hyperparameters: The hyperparameters of this part, if any, enclosed in curly + braces. + operands: The operands of this part, which are the remaining content in the + parenthesis. + """ + + level: int + opening_index: int + operator: str | Callable[..., Any] + hyperparameters: str + operands: str + + +@functools.lru_cache(maxsize=2000) +def unwrap_config_string(config_string: str) -> tuple[UnwrappedConfigStringPart, ...]: + """For a given config string, gets the parenthetic contents of it + and uses them to construct objects of type `UnwrappedConfigStringPart`. + First unwraps a given parenthesised config_string into parts. + Then it converts these parts into objects with structured information. + + Args: + config_string: The configuration string to be unwrapped. + + Returns: + A tuple of `UnwrappedConfigStringPart` objects representing the unwrapped + configuration string. + """ + # A workaround needed since in the existing configurations + # generated by previous methods, e.g. the `resBlock resBlock` and `resBlock` items + # occur without wrapping parenthesis, differently from other items. + # Wrap them appropriately in parentheses here and in the inverse process. + # For example 'id' comes in two forms: 'id id' and 'Ops id', + # only the 'id id' variant should be replaced. + replacements = [ + ("resBlock", True), + ("id", False), + ] + for op, replace_individual in replacements: + config_string = config_string.replace(f"{op} {op}", "__TMP_PLACEHOLDER___") + if replace_individual: + config_string = config_string.replace(f"{op}", f"({op})") + config_string = config_string.replace("__TMP_PLACEHOLDER___", f"({op} {op})") + + result = [] + + stack = [] + opening_counter = 0 + for current_char_index, current_char in enumerate(config_string): + if current_char == "(": + stack.append((current_char_index, opening_counter)) + opening_counter += 1 + elif current_char == ")": + assert stack, f"Found ')' with no matching '('. Index: {current_char_index}" + + start_char_index, opening_index = stack.pop() + level = len(stack) + 1 # start level counting from 1 and not 0 + + value_single = config_string[start_char_index + 1 : current_char_index] + value = value_single.split(" (", maxsplit=1) + operator = value[0] + operands = "(" + value[1] if len(value) > 1 else "" + + if " {" in operator: + operator, hyperparameters = operator.split(" {") + hyperparameters = "{" + hyperparameters + else: + hyperparameters = "{}" + + item = UnwrappedConfigStringPart( + level=level, + opening_index=opening_index, + operator=operator, + hyperparameters=hyperparameters, + operands=operands, + ) + result.append(item) + + assert not stack, f"For '(' found no matching ')': Index: {stack[0][0]}" + return tuple(sorted(result, key=lambda x: x.opening_index)) + + +# Current profiling shows this function does not run that often +# so no need for caching +def wrap_config_into_string( + unwrapped_config: tuple[UnwrappedConfigStringPart, ...], + max_level: int | None = None, +) -> str: + """For a given unwrapped config, returns the string representing it. + + Args: + unwrapped_config: The unwrapped config + max_level: An optional int telling which is the maximal considered level. + Bigger levels are ignored. + + Returns: + The string representation of the unwrapped config. + """ + result = [] + current_level = 0 + for item in unwrapped_config: + if max_level is not None and item.level > max_level: + continue + + if item.level > current_level: + if item.hyperparameters not in ("{}", ""): + value = ( + " (" + + str( + item.operator.__name__ + if callable(item.operator) + else item.operator + ) + + " " + + item.hyperparameters + ) + else: + value = " (" + str( + item.operator.__name__ if callable(item.operator) else item.operator + ) + elif item.level < current_level: + value = ( + ")" * (current_level - item.level + 1) + + " (" + + str( + item.operator.__name__ if callable(item.operator) else item.operator + ) + ) + else: + value = ") (" + str( + item.operator.__name__ if callable(item.operator) else item.operator + ) + current_level = item.level + result.append(value) + result.append(")" * current_level) + + result_string = "".join(result).strip() + + # A workaround needed since in the existing configurations + # generated by previous methods, e.g. the `resBlock resBlock` and `resBlock` items + # occur without wrapping parenthesis, differently from other items. + # Wrap them appropriately in parentheses here and in the inverse process. + # For example 'id' comes in two forms: 'id id' and 'Ops id', + # only the 'id id' variant should be replaced. + replacements = [ + ("resBlock", True), + ("id", False), + ] + for op, replace_individual in replacements: + result_string = result_string.replace(f"({op} {op})", "__TMP_PLACEHOLDER___") + if replace_individual: + result_string = result_string.replace(f"({op})", f"{op}") + result_string = result_string.replace("__TMP_PLACEHOLDER___", f"{op} {op}") + return result_string + + +class ConfigString: + """A class representing a configuration string in NePS spaces. + It provides methods to unwrap the configuration string into structured parts, + retrieve the maximum hierarchy level, and get a representation of the configuration + at a specific hierarchy level. + """ + + def __init__(self, config_string: str) -> None: + """Initialize the ConfigString with a given configuration string. + + Args: + config_string: The configuration string to be wrapped. + + Raises: + ValueError: If the config_string is None or empty. + """ + if config_string is None or len(config_string) == 0: + raise ValueError(f"Invalid config string: {config_string}") + self.config_string = config_string + + # The fields below are needed for lazy and cached evaluation. + # In python 3.8+ can be replaced by `cached_property` + self._unwrapped: tuple[UnwrappedConfigStringPart, ...] | None = None + self._max_hierarchy_level: int | None = None + + # a cache for the different hierarchy levels of this config string + self._at_hierarchy_level_cache: dict[int, ConfigString] = {} + + @property + def unwrapped(self) -> tuple[UnwrappedConfigStringPart, ...]: + """Get the unwrapped representation of the configuration string. + + Returns: + A tuple of UnwrappedConfigStringPart objects representing the unwrapped + config. + + Raises: + ValueError: If there is an error unwrapping the config string. + """ + # If the unwrapped is already cached, return it + if self._unwrapped is not None: + return self._unwrapped + + unwrapped = unwrap_config_string(self.config_string) + if not unwrapped: + raise ValueError(f"Error unwrapping config string: {self.config_string}") + + # NOTE: Previously, here was a test that compared wrap_config_into_string + # (unwrapped_config=unwrapped) to unwrapped. As it frequently failed and was + # deemed to be unnecessary, it was removed + + self._unwrapped = unwrapped + return self._unwrapped + + @property + def max_hierarchy_level(self) -> int: + """Get the maximum hierarchy level of the configuration string. + + Returns: + The maximum hierarchy level of the configuration string. + + Raises: + ValueError: If the maximum hierarchy level is invalid. + """ + if self._max_hierarchy_level is not None: + return self._max_hierarchy_level + + max_hierarchy_level = max(i.level for i in self.unwrapped) + assert max_hierarchy_level > 0, ( + f"Invalid max hierarchy level: {self.max_hierarchy_level}" + ) + + self._max_hierarchy_level = max_hierarchy_level + return self._max_hierarchy_level + + def at_hierarchy_level(self, level: int) -> ConfigString: + """Get the configuration string at a specific hierarchy level. + + Args: + level: The hierarchy level to retrieve the configuration string for. + + Returns: + A ConfigString object representing the configuration at the specified + hierarchy level. + + Raises: + ValueError: If the level is invalid (0 or out of bounds). + """ + if level == 0: + raise ValueError(f"Invalid value for `level`. Received level == 0: {level}") + if level > self.max_hierarchy_level: + raise ValueError( + "Invalid value for `level`. " + + f"level>max_hierarchy_level: {level}>{self.max_hierarchy_level}" + ) + if level < -self.max_hierarchy_level: + raise ValueError( + "Invalid value for `level`. " + + f"level<-max_hierarchy_level: {level}<-{self.max_hierarchy_level}" + ) + + if level < 0: + # for example for level=-1, when max_hierarchy_level=7, new level is 7 + # for example for level=-3, when max_hierarchy_level=7, new level is 5 + level = self.max_hierarchy_level + (level + 1) + + if level in self._at_hierarchy_level_cache: + return self._at_hierarchy_level_cache[level] + + config_string_at_hierarchy_level = wrap_config_into_string( + unwrapped_config=self.unwrapped, max_level=level + ) + config_at_hierarchy_level = ConfigString(config_string_at_hierarchy_level) + self._at_hierarchy_level_cache[level] = config_at_hierarchy_level + + return self._at_hierarchy_level_cache[level] + + def pretty_format(self) -> str: + """Get a pretty formatted string representation of the configuration string. + + Returns: + A string representation of the configuration string with indentation + based on the hierarchy level of each part. + """ + format_str_with_kwargs = ( + "{indent}{item.level:0>2d} :: {item.operator} {item.hyperparameters}" + ) + format_str_no_kwargs = "{indent}{item.level:0>2d} :: {item.operator}" + lines = [self.config_string] + for item in self.unwrapped: + if item.hyperparameters not in {"{}", ""}: + line = format_str_with_kwargs.format(item=item, indent="\t" * item.level) + else: + line = format_str_no_kwargs.format(item=item, indent="\t" * item.level) + lines.append(line) + return "\n".join(lines) + + def __eq__(self, other: object) -> bool: + if isinstance(other, self.__class__): + return self.config_string == other.config_string + raise NotImplementedError() # let the other side check for equality + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def __hash__(self) -> int: + return self.config_string.__hash__() diff --git a/neps/space/neps_spaces/neps_space.py b/neps/space/neps_spaces/neps_space.py new file mode 100644 index 000000000..ebee6c7cb --- /dev/null +++ b/neps/space/neps_spaces/neps_space.py @@ -0,0 +1,1483 @@ +"""This module provides functionality for resolving NePS spaces, including sampling from +domains, resolving pipelines, and handling various resolvable objects. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import functools +from collections.abc import Callable, Generator, Mapping +from functools import partial +from typing import TYPE_CHECKING, Any, Concatenate, Literal, TypeVar, cast + +import neps +from neps.optimizers import algorithms, optimizer +from neps.space.neps_spaces import config_string +from neps.space.neps_spaces.parameters import ( + _UNSET, + Categorical, + Domain, + Fidelity, + Float, + Integer, + Lazy, + Operation, + PipelineSpace, + Repeated, + Resampled, + Resolvable, +) +from neps.space.neps_spaces.sampling import ( + DomainSampler, + OnlyPredefinedValuesSampler, + RandomSampler, +) +from neps.space.parsing import convert_mapping + +if TYPE_CHECKING: + from neps.space import SearchSpace + +P = TypeVar("P", bound="PipelineSpace") + + +class SamplingResolutionContext: + """A context for resolving samplings in a NePS space. + It manages the resolution root, domain sampler, environment values, + and keeps track of samplings made and resolved objects. + + Args: + resolution_root: The root of the resolution, which should be a Resolvable + object. + domain_sampler: The DomainSampler to use for sampling from Domain objects. + environment_values: A mapping of environment values that are fixed and not + related to samplings. These values can be used in the resolution process. + + Raises: + ValueError: If the resolution_root is not a Resolvable, or if the domain_sampler + is not a DomainSampler, or if the environment_values is not a Mapping. + """ + + def __init__( + self, + *, + resolution_root: Resolvable, + domain_sampler: DomainSampler, + environment_values: Mapping[str, Any], + ): + """Initialize the SamplingResolutionContext with a resolution root, domain + sampler, and environment values. + + Args: + resolution_root: The root of the resolution, which should be a Resolvable + object. + domain_sampler: The DomainSampler to use for sampling from Domain objects. + environment_values: A mapping of environment values that are fixed and not + related to samplings. These values can be used in the resolution process. + + Raises: + ValueError: If the resolution_root is not a Resolvable, or if the + domain_sampler is not a DomainSampler, or if the environment_values is + not a Mapping. + """ + if not isinstance(resolution_root, Resolvable): + raise ValueError( + "The received `resolution_root` is not a Resolvable:" + f" {resolution_root!r}." + ) + + if not isinstance(domain_sampler, DomainSampler): + raise ValueError( + "The received `domain_sampler` is not a DomainSampler:" + f" {domain_sampler!r}." + ) + + if not isinstance(environment_values, Mapping): + raise ValueError( + "The received `environment_values` is not a Mapping:" + f" {environment_values!r}." + ) + + # `_resolution_root` stores the root of the resolution. + self._resolution_root: Resolvable = resolution_root + + # `_domain_sampler` stores the object responsible for sampling from Domain + # objects. + self._domain_sampler = domain_sampler + + # # `_environment_values` stores fixed values from outside. + # # They are not related to samplings and can not be mutated or similar. + self._environment_values = environment_values + + # `_samplings_made` stores the values we have sampled + # and can be used later in case we want to redo a resolving. + self._samplings_made: dict[str, Any] = {} + + # `_resolved_objects` stores the intermediate values to make re-use possible. + self._resolved_objects: dict[Any, Any] = {} + + # `_sampled_domains` tracks domain objects that have been sampled from by + # (id, path) key + self._sampled_domains: set[tuple[int, str]] = set() + + # `_current_path_parts` stores the current path we are resolving. + self._current_path_parts: list[str] = [] + + @property + def resolution_root(self) -> Resolvable: + """Get the root of the resolution. + + Returns: + The root of the resolution, which should be a Resolvable object. + """ + return self._resolution_root + + @property + def samplings_made(self) -> Mapping[str, Any]: + """Get the samplings made during the resolution process. + + Returns: + A mapping of paths to sampled values. + """ + return self._samplings_made + + @property + def environment_values(self) -> Mapping[str, Any]: + """Get the environment values that are fixed and not related to samplings. + + Returns: + A mapping of environment variable names to their values. + """ + return self._environment_values + + @contextlib.contextmanager + def resolving(self, _obj: Any, name: str) -> Generator[None]: + """Context manager for resolving an object in the current resolution context. + + Args: + _obj: The object being resolved, can be any type. + name: The name of the object being resolved, used for debugging. + + Raises: + ValueError: If the name is not a valid string. + """ + if not name or not isinstance(name, str): + raise ValueError( + f"Given name for what we are resolving is invalid: {name!r}." + ) + + # It is possible that the received object has already been resolved. + # That is expected and is okay, so no check is made for it. + # For example, in the case of a Resampled we can receive the same object again. + + self._current_path_parts.append(name) + try: + yield + finally: + self._current_path_parts.pop() + + def was_already_resolved(self, obj: Any) -> bool: + """Check if the given object was already resolved in the current context. + + Args: + obj: The object to check if it was already resolved. + + Returns: + True if the object was already resolved, False otherwise. + """ + try: + # Try to use the object itself as a key (works for hashable objects) + return obj in self._resolved_objects + except TypeError: + # If the object is not hashable, fall back to using its id + return id(obj) in self._resolved_objects + + def add_resolved(self, original: Any, resolved: Any) -> None: + """Add a resolved object to the context. + + Args: + original: The original object that was resolved. + resolved: The resolved value of the original object. + + Raises: + ValueError: If the original object was already resolved or if it is a + Resampled. + """ + if self.was_already_resolved(original): + raise ValueError( + f"Original object has already been resolved: {original!r}. " + + "\nIf you are doing resampling by name, " + + "make sure you are not forgetting to request resampling also for" + " related objects." + "\nOtherwise it could lead to infinite recursion." + ) + if isinstance(original, Resampled): + raise ValueError( + f"Attempting to add a Resampled object to resolved values: {original!r}." + ) + try: + # Try to use the object itself as a key (works for hashable objects) + self._resolved_objects[original] = resolved + except TypeError: + # If the object is not hashable, fall back to using its id + self._resolved_objects[id(original)] = resolved + + def get_resolved(self, obj: Any) -> Any: + """Get the resolved value for the given object. + + Args: + obj: The object for which to get the resolved value. + + Returns: + The resolved value of the object. + + Raises: + ValueError: If the object was not already resolved in the context. + """ + try: + # Try to use the object itself as a key (works for hashable objects) + return self._resolved_objects[obj] + except (KeyError, TypeError) as err: + if isinstance(err, TypeError): + # If the object is not hashable, try using its id + try: + return self._resolved_objects[id(obj)] + except KeyError as id_err: + raise ValueError( + "Given object was not already resolved. Please check first:" + f" {obj!r}" + ) from id_err + else: + # KeyError - object wasn't found + raise ValueError( + f"Given object was not already resolved. Please check first: {obj!r}" + ) from err + + def sample_from(self, domain_obj: Domain) -> Any: + """Sample a value from the given domain object. + + Args: + domain_obj: The domain object from which to sample a value. + + Returns: + The sampled value from the domain object. + + Raises: + ValueError: If the domain object was already resolved or if the path + has already been sampled from. + """ + # The range compatibility identifier is there to make sure when we say + # the path matches, that the range for the value we are looking up also matches. + domain_obj_type_name = type(domain_obj).__name__.lower() + range_compatibility_identifier = domain_obj.range_compatibility_identifier + domain_obj_identifier = ( + f"{domain_obj_type_name}__{range_compatibility_identifier}" + ) + + current_path = ".".join(self._current_path_parts) + current_path += "::" + domain_obj_identifier + + if current_path in self._samplings_made: + # We have already sampled a value for this path. This should not happen. + # Every time we sample a domain, it should have its own different path. + raise ValueError( + f"We have already sampled a value for the current path: {current_path!r}." + + "\nThis should not be happening." + ) + + # For domain object tracking, create a key that includes both domain ID and path + # context + # This allows the same domain to be sampled in different resampled contexts + domain_path_key = (id(domain_obj), current_path) + + if domain_path_key in self._sampled_domains: + raise ValueError( + "We have already sampled a value for the given domain object:" + f" {domain_obj!r} at path {current_path!r}." + + "\nThis should not be happening." + ) + + sampled_value = self._domain_sampler( + domain_obj=domain_obj, + current_path=current_path, + ) + + self._samplings_made[current_path] = sampled_value + self._sampled_domains.add(domain_path_key) + return self._samplings_made[current_path] + + def get_value_from_environment(self, var_name: str) -> Any: + """Get a value from the environment variables. + + Args: + var_name: The name of the environment variable to get the value from. + + Returns: + The value of the environment variable. + + Raises: + ValueError: If the environment variable is not found in the context. + """ + try: + return self._environment_values[var_name] + except KeyError as err: + raise ValueError( + f"No value is available for the environment variable {var_name!r}." + ) from err + + def was_already_resolved_with_path(self, obj: Any) -> bool: + """Check if the given object was already resolved with current path. + + Args: + obj: The object to check if it was already resolved. + + Returns: + True if the object was already resolved with current path, False otherwise. + """ + current_path = ".".join(self._current_path_parts) + # Use object identity (id) instead of equality to ensure different instances + # are treated separately even if they're equal + cache_key = (id(obj), current_path) + return cache_key in self._resolved_objects + + def get_resolved_with_path(self, obj: Any) -> Any: + """Get the resolved value for the given object with current path. + + Args: + obj: The object for which to get the resolved value. + + Returns: + The resolved value of the object. + + Raises: + ValueError: If the object was not already resolved with current path. + """ + current_path = ".".join(self._current_path_parts) + cache_key = (id(obj), current_path) + try: + return self._resolved_objects[cache_key] + except KeyError as err: + raise ValueError( + f"Given object was not already resolved with path {current_path!r}:" + f" {obj!r}" + ) from err + + def add_resolved_with_path(self, original: Any, resolved: Any) -> None: + """Add a resolved object to the context with current path. + + Args: + original: The original object that was resolved. + resolved: The resolved value of the original object. + """ + current_path = ".".join(self._current_path_parts) + cache_key = (id(original), current_path) + self._resolved_objects[cache_key] = resolved + + +class SamplingResolver: + """A class responsible for resolving samplings in a NePS space. + It uses a SamplingResolutionContext to manage the resolution process, + and a DomainSampler to sample values from Domain objects. + """ + + def __call__( + self, + obj: Resolvable, + domain_sampler: DomainSampler, + environment_values: Mapping[str, Any], + ) -> tuple[Resolvable, SamplingResolutionContext]: + """Resolve the given object in the context of the provided domain sampler and + environment values. + + Args: + obj: The Resolvable object to resolve. + domain_sampler: The DomainSampler to use for sampling from Domain objects. + environment_values: A mapping of environment values that are fixed and not + related to samplings. + + Returns: + A tuple containing the resolved object and the + SamplingResolutionContext. + + Raises: + ValueError: If the object is not a Resolvable, or if the domain_sampler + is not a DomainSampler, or if the environment_values is not a Mapping. + """ + context = SamplingResolutionContext( + resolution_root=obj, + domain_sampler=domain_sampler, + environment_values=environment_values, + ) + return self._resolve(obj, "Resolvable", context), context + + def _resolve(self, obj: Any, name: str, context: SamplingResolutionContext) -> Any: + with context.resolving(obj, name): + return self._resolver_dispatch(obj, context) + + @functools.singledispatchmethod + def _resolver_dispatch( + self, + any_obj: Any, + _context: SamplingResolutionContext, + ) -> Any: + # Default resolver. To be used for types which are not instances of `Resolvable`. + # No need to store or lookup from context, directly return the given object. + if isinstance(any_obj, Resolvable): + raise ValueError( + "The default resolver is not supposed to be called for resolvable" + f" objects. Received: {any_obj!r}." + ) + return any_obj + + @_resolver_dispatch.register + def _( + self, + pipeline_obj: PipelineSpace, + context: SamplingResolutionContext, + ) -> Any: + if context.was_already_resolved(pipeline_obj): + return context.get_resolved(pipeline_obj) + + initial_attrs = pipeline_obj.get_attrs() + final_attrs = {} + needed_resolving = False + + for attr_name, initial_attr_value in initial_attrs.items(): + resolved_attr_value = self._resolve(initial_attr_value, attr_name, context) + final_attrs[attr_name] = resolved_attr_value + needed_resolving = needed_resolving or ( + initial_attr_value is not resolved_attr_value + ) + + result = pipeline_obj + if needed_resolving: + result = pipeline_obj.from_attrs(final_attrs) + + context.add_resolved(pipeline_obj, result) + return result + + @_resolver_dispatch.register + def _( + self, + domain_obj: Domain, + context: SamplingResolutionContext, + ) -> Any: + # Check if we're in a resampled context (path contains "resampled_") + current_path = ".".join(context._current_path_parts) + is_resampled_context = "resampled_" in current_path + + # Always check object-identity cache first for shared objects + if context.was_already_resolved(domain_obj): + return context.get_resolved(domain_obj) + + # In non-resampled contexts, also check path-specific cache + if not is_resampled_context and context.was_already_resolved_with_path( + domain_obj + ): + return context.get_resolved_with_path(domain_obj) + + initial_attrs = domain_obj.get_attrs() + final_attrs = {} + needed_resolving = False + + for attr_name, initial_attr_value in initial_attrs.items(): + resolved_attr_value = self._resolve(initial_attr_value, attr_name, context) + final_attrs[attr_name] = resolved_attr_value + needed_resolving = needed_resolving or ( + initial_attr_value is not resolved_attr_value + ) + + resolved_domain_obj = domain_obj + if needed_resolving: + resolved_domain_obj = domain_obj.from_attrs(final_attrs) + + try: + sampled_value = context.sample_from(resolved_domain_obj) + except Exception as e: + raise ValueError(f"Failed to sample from {resolved_domain_obj!r}.") from e + result = self._resolve(sampled_value, "sampled_value", context) + + # Cache the result + if not is_resampled_context: + # In normal contexts, cache with both object identity and path + context.add_resolved(domain_obj, result) + context.add_resolved_with_path(domain_obj, result) + elif not context.was_already_resolved(domain_obj): + # In resampled contexts, cache shared objects for reuse across contexts + # but skip path-based caching to prevent unwanted dependencies + context.add_resolved(domain_obj, result) + + return result + + @_resolver_dispatch.register + def _( + self, + categorical_obj: Categorical, + context: SamplingResolutionContext, + ) -> Any: + if context.was_already_resolved(categorical_obj): + return context.get_resolved(categorical_obj) + + # In the case of categorical choices, we may skip resolving each choice initially, + # only after sampling we go into resolving whatever choice was chosen. + # This avoids resolving things which won't be needed at all. + # If the choices themselves come from some Resolvable, they will be resolved. + + initial_attrs = categorical_obj.get_attrs() + final_attrs = {} + needed_resolving = False + + for attr_name, initial_attr_value in initial_attrs.items(): + if attr_name == "choices": + # We need special handling if we are dealing with a "choice provider", + # which will select a tuple of choices from its own choices, + # from which then this original categorical will pick. + + # Ideally, from the choices provided, we want to first pick one, + # and then only resolve that picked item. + # We don't want the resolution process to directly go inside + # the tuple of provided choices that gets picked from the provider, + # since that would lead to potentially exponential growth + # and in resolving stuff that will ultimately be useless to us. + + # For this reason, if we haven't already sampled this categorical + # (the choice provider), we make sure to wrap each of the choices + # inside it in a lazy resolvable. + # This ensures that the resolving process stops directly after + # the provider has made its choice. + + # Since we may be manually creating a new categorical object + # for the provider, which is what will then get resolved, + # it's important that we manually store + # in the context that resolved value for the original object. + # The original object can possibly be reused elsewhere. + + if isinstance( + initial_attr_value, Categorical + ) and context.was_already_resolved(initial_attr_value): + # Before making adjustments, we make sure we haven't + # already chosen a value for the provider. + # Otherwise, we already have the final answer for it. + resolved_attr_value = context.get_resolved(initial_attr_value) + elif isinstance(initial_attr_value, Categorical) or ( + isinstance(initial_attr_value, Resampled) + and isinstance(initial_attr_value.source, Categorical) + ): + # We have a previously unseen provider. + # Create a new object where the choices are lazy, + # and then sample from it, manually tracking the context. + + choice_provider_final_attrs = {**initial_attr_value.get_attrs()} + choice_provider_choices = choice_provider_final_attrs["choices"] + if isinstance(choice_provider_choices, tuple | list): + choice_provider_choices = tuple( + Lazy(content=choice) for choice in choice_provider_choices + ) + choice_provider_final_attrs["choices"] = choice_provider_choices + choice_provider_adjusted = initial_attr_value.from_attrs( + choice_provider_final_attrs + ) + + resolved_attr_value = self._resolve( + choice_provider_adjusted, "choice_provider", context + ) + if not isinstance(initial_attr_value, Resampled): + # It's important that we handle filling the context here, + # as we manually created a different object from the original. + # In case the original categorical is used again, + # it will need to be reused with the final value we resolved. + context.add_resolved(initial_attr_value, resolved_attr_value) + else: + # We have "choices" which are ready to use. + resolved_attr_value = initial_attr_value + else: + resolved_attr_value = self._resolve( + initial_attr_value, attr_name, context + ) + final_attrs[attr_name] = resolved_attr_value + needed_resolving = needed_resolving or ( + initial_attr_value is not resolved_attr_value + ) + + resolved_categorical_obj = categorical_obj + if needed_resolving: + resolved_categorical_obj = cast( + Categorical, categorical_obj.from_attrs(final_attrs) + ) + + try: + sampled_index = context.sample_from(resolved_categorical_obj) + except Exception as e: + raise ValueError( + f"Failed to sample from {resolved_categorical_obj!r}." + ) from e + sampled_value = cast(tuple, resolved_categorical_obj.choices)[sampled_index] + result = self._resolve(sampled_value, "sampled_value", context) + + context.add_resolved(categorical_obj, result) + return result + + @_resolver_dispatch.register + def _( + self, + operation_obj: Operation, + context: SamplingResolutionContext, + ) -> Any: + if context.was_already_resolved(operation_obj): + return context.get_resolved(operation_obj) + + initial_attrs = operation_obj.get_attrs() + final_attrs = {} + needed_resolving = False + + for attr_name, initial_attr_value in initial_attrs.items(): + resolved_attr_value = self._resolve(initial_attr_value, attr_name, context) + final_attrs[attr_name] = resolved_attr_value + needed_resolving = needed_resolving or ( + initial_attr_value is not resolved_attr_value + ) + + result = operation_obj + if needed_resolving: + result = operation_obj.from_attrs(final_attrs) + + context.add_resolved(operation_obj, result) + return result + + @_resolver_dispatch.register + def _( + self, + resampled_obj: Resampled, + context: SamplingResolutionContext, + ) -> Any: + # The results of Resampled are never stored or looked up from cache + # since it would break the logic of their expected behavior. + # Particularly, when Resampled objects are nested (at any depth) inside of + # other Resampled objects, adding them to the resolution context would result + # in the resolution not doing the right thing. + + if resampled_obj.is_resampling_by_name: + # We are dealing with a resampling by name, + # We will first need to look up the source object referenced by name. + # That will then be the object to resample. + referenced_obj_name = cast(str, resampled_obj.source) + referenced_obj = getattr(context.resolution_root, referenced_obj_name) + resampled_obj = Resampled(referenced_obj) + + initial_attrs = resampled_obj.get_attrs() + resolvable_to_resample_obj = resampled_obj.from_attrs(initial_attrs) + + if resolvable_to_resample_obj is resampled_obj.source: + # The final resolvable we are resolving needs to be a different + # instance from the original wrapped object. + # Otherwise, it's possible we'll be taking its result + # from the context cache, instead of resampling it. + raise ValueError( + "The final object must be a different instance from the original: " + f"{resolvable_to_resample_obj!r}" + ) + + type_name = type(resolvable_to_resample_obj).__name__.lower() + return self._resolve( + resolvable_to_resample_obj, f"resampled_{type_name}", context + ) + + @_resolver_dispatch.register + def _( + self, + fidelity_obj: Fidelity, + context: SamplingResolutionContext, + ) -> Any: + # A Fidelity object should only really be used in one place, + # so we check if we have seen it before. + # For that we will be storing its result in the resolved cache. + if context.was_already_resolved(fidelity_obj): + raise ValueError("Fidelity object reused multiple times in the pipeline.") + + # The way resolution works for Fidelity objects is that + # we use the domain inside it only to know the bounds for valid values. + # The actual value for the fidelity comes from the outside in the form of an + # environment value, which we look up by the attribute name of the + # received fidelity object inside the resolution root. + + names_for_this_fidelity_obj = [ + attr_name + for attr_name, attr_value in context.resolution_root.get_attrs().items() + if attr_value is fidelity_obj + ] + + if len(names_for_this_fidelity_obj) == 0: + raise ValueError( + "A fidelity object should be a direct attribute of the pipeline." + ) + if len(names_for_this_fidelity_obj) > 1: + raise ValueError( + "A fidelity object should only be referenced once in the pipeline." + ) + + fidelity_name = names_for_this_fidelity_obj[0] + + try: + result = context.get_value_from_environment(fidelity_name) + except ValueError as err: + raise ValueError( + "No value is available in the environment for fidelity" + f" {fidelity_name!r}." + ) from err + + if not fidelity_obj.min_value <= result <= fidelity_obj.max_value: + raise ValueError( + f"Value for fidelity with name {fidelity_name!r} is outside its allowed" + " range " + + f"[{fidelity_obj.min_value!r}, {fidelity_obj.max_value!r}]. " + + f"Received: {result!r}." + ) + + context.add_resolved(fidelity_obj, result) + return result + + @_resolver_dispatch.register + def _( + self, + repeated_resolvable_obj: Repeated, + context: SamplingResolutionContext, + ) -> tuple[Any]: + if context.was_already_resolved(repeated_resolvable_obj): + return context.get_resolved(repeated_resolvable_obj) + + # First figure out how many times we need to resolvable repeated, + # then do that many resolves of that object. + # It does not matter what type the content is. + # Return all the results as a tuple. + + unresolved_count = repeated_resolvable_obj.count + resolved_count = self._resolve(unresolved_count, "repeat_count", context) + + if not isinstance(resolved_count, int): + raise ValueError( + f"The resolved count value for {repeated_resolvable_obj!r} is not an int." + f" Resolved to {resolved_count!r}" + ) + + obj_to_repeat = repeated_resolvable_obj.content + result = [] + for i in range(resolved_count): + result.append(self._resolve(obj_to_repeat, f"repeated_item[{i}]", context)) + result = tuple(result) # type: ignore[assignment] + + context.add_resolved(repeated_resolvable_obj, result) + return result # type: ignore[return-value] + + @_resolver_dispatch.register + def _( + self, + resolvable_obj: Lazy, + context: SamplingResolutionContext, # noqa: ARG002 + ) -> Any: + # When resolving a lazy resolvable, + # just directly return the content it's holding. + # The purpose of the lazy resolvable is to stop + # the resolver from going deeper into the process. + # In this case, to stop the resolution of `resolvable_obj.content`. + # No need to add it in the resolved cache. + return resolvable_obj.content + + @_resolver_dispatch.register + def _( + self, + resolvable_obj: dict, + context: SamplingResolutionContext, + ) -> dict[Any, Any]: + # The logic below is done so that if the original dict + # had only things that didn't need resolving, + # we return the original object. + # That is important for the rest of the resolving process. + original_dict = resolvable_obj + new_dict = {} + needed_resolving = False + + for k, initial_v in original_dict.items(): + resolved_v = self._resolve(initial_v, f"mapping_value{{{k}}}", context) + new_dict[k] = resolved_v + needed_resolving = needed_resolving or (resolved_v is not initial_v) + + result = original_dict + if needed_resolving: + result = new_dict + + # TODO: [lum] reconsider this below. We likely should cache them, + # similarly to other things. + # IMPORTANT: Dicts are not stored in the resolved cache. + # Otherwise, we won't go inside them the next time + # and will ignore any resampled things inside. + return result + + @_resolver_dispatch.register + def _( + self, + resolvable_obj: tuple, + context: SamplingResolutionContext, + ) -> tuple[Any]: + return self._resolve_sequence(resolvable_obj, context) # type: ignore[return-value] + + @_resolver_dispatch.register + def _( + self, + resolvable_obj: list, + context: SamplingResolutionContext, + ) -> list[Any]: + return self._resolve_sequence(resolvable_obj, context) # type: ignore[return-value] + + def _resolve_sequence( + self, + resolvable_obj: tuple | list, + context: SamplingResolutionContext, + ) -> tuple[Any] | list[Any]: + # The logic below is done so that if the original sequence + # had only things that didn't need resolving, + # we return the original object. + # That is important for the rest of the resolving process. + original_sequence = resolvable_obj + new_list = [] + needed_resolving = False + + for idx, initial_item in enumerate(original_sequence): + resolved_item = self._resolve(initial_item, f"sequence[{idx}]", context) + new_list.append(resolved_item) + needed_resolving = needed_resolving or (initial_item is not resolved_item) + + result = original_sequence + if needed_resolving: + # We also want to return a result of the same type + # as the original received sequence. + original_type = type(original_sequence) + result = original_type(new_list) + + # TODO: [lum] reconsider this below. We likely should cache them, + # similarly to other things. + # IMPORTANT: Sequences are not stored in the resolved cache. + # Otherwise, we won't go inside them the next time + # and will ignore any resampled things inside. + return result + + @_resolver_dispatch.register + def _( + self, + resolvable_obj: Resolvable, + context: SamplingResolutionContext, # noqa: ARG002 + ) -> Any: + # Called when no specialized resolver was available for the specific resolvable + # type. That is not something that is normally expected. + raise ValueError( + "No specialized resolver was registered for object of type" + f" {type(resolvable_obj)!r}." + ) + + +def resolve( + pipeline: P, + domain_sampler: DomainSampler | None = None, + environment_values: Mapping[str, Any] | None = None, +) -> tuple[P, SamplingResolutionContext]: + """Resolve a NePS pipeline with the given domain sampler and environment values. + + Args: + pipeline: The pipeline to resolve, which should be a Pipeline object. + domain_sampler: The DomainSampler to use for sampling from Domain objects. + If None, a RandomSampler with no predefined values will be used. + environment_values: A mapping of environment variable names to their values. + If None, an empty mapping will be used. + + Returns: + A tuple containing the resolved pipeline and the SamplingResolutionContext. + + Raises: + ValueError: If the pipeline is not a Pipeline object or if the domain_sampler + is not a DomainSampler or if the environment_values is not a Mapping. + """ + if domain_sampler is None: + # By default, use a random sampler with no predefined values. + domain_sampler = RandomSampler(predefined_samplings={}) + + if environment_values is None: + # By default, have no environment values. + environment_values = {} + + sampling_resolver = SamplingResolver() + resolved_pipeline, context = sampling_resolver( + obj=pipeline, + domain_sampler=domain_sampler, + environment_values=environment_values, + ) + return cast(P, resolved_pipeline), context + + +# ------------------------------------------------- + + +def convert_operation_to_callable(operation: Operation) -> Callable: + """Convert an Operation to a callable that can be executed. + + Args: + operation: The Operation to convert. + + Returns: + A callable that represents the operation. + + Raises: + ValueError: If the operation is not a valid Operation object. + """ + operator = cast(Callable, operation.operator) + + operation_args: list[Any] = [] + for arg in ( + operation.args if isinstance(operation.args, tuple | list) else (operation.args,) + ): + if isinstance(arg, tuple | list): + arg_sequence: list[Any] = [] + for a in arg: + converted_arg = ( + convert_operation_to_callable(a) if isinstance(a, Operation) else a + ) + arg_sequence.append(converted_arg) + if isinstance(arg, tuple): + operation_args.append(tuple(arg_sequence)) + else: + operation_args.append(arg_sequence) + else: + operation_args.append( + convert_operation_to_callable(arg) if isinstance(arg, Operation) else arg + ) + + operation_kwargs: dict[str, Any] = {} + for kwarg_name, kwarg_value in operation.kwargs.items(): + if isinstance(kwarg_value, tuple | list): + kwarg_sequence: list[Any] = [] + for a in kwarg_value: + converted_kwarg = ( + convert_operation_to_callable(a) if isinstance(a, Operation) else a + ) + kwarg_sequence.append(converted_kwarg) + if isinstance(kwarg_value, tuple): + operation_kwargs[kwarg_name] = tuple(kwarg_sequence) + else: + operation_kwargs[kwarg_name] = kwarg_sequence + else: + operation_kwargs[kwarg_name] = ( + convert_operation_to_callable(kwarg_value) + if isinstance(kwarg_value, Operation) + else kwarg_value + ) + return cast(Callable, operator(*operation_args, **operation_kwargs)) + + +def _operation_to_unwrapped_config( + operation: Operation | str | Resolvable, + level: int = 1, +) -> list[config_string.UnwrappedConfigStringPart]: + result = [] + + if isinstance(operation, Operation): + operator = operation.operator + kwargs = str(operation.kwargs) + item = config_string.UnwrappedConfigStringPart( + level=level, + opening_index=-1, + operator=operator, + hyperparameters=kwargs, + operands="", + ) + result.append(item) + + # Handle args that might be a Resolvable or an iterable + args = operation.args + if isinstance(args, Resolvable): + # If args is a Resolvable, treat it as a single operand + result.extend(_operation_to_unwrapped_config(args, level + 1)) + else: + # If args is iterable (tuple/list), iterate over each operand + for operand in args: + result.extend(_operation_to_unwrapped_config(operand, level + 1)) + elif isinstance(operation, Resolvable): + # Handle other Resolvable types that are not Operations + item = config_string.UnwrappedConfigStringPart( + level=level, + opening_index=-1, + operator=str(operation), # Convert to string for display + hyperparameters="", + operands="", + ) + result.append(item) + else: + # Handle string operations + item = config_string.UnwrappedConfigStringPart( + level=level, + opening_index=-1, + operator=operation, + hyperparameters="", + operands="", + ) + result.append(item) + return result + + +def convert_operation_to_string(operation: Operation) -> str: + """Convert an Operation to a string representation. + + Args: + operation: The Operation to convert. + + Returns: + A string representation of the operation. + + Raises: + ValueError: If the operation is not a valid Operation object. + """ + unwrapped_config = tuple(_operation_to_unwrapped_config(operation)) + return config_string.wrap_config_into_string(unwrapped_config) + + +# ------------------------------------------------- + + +class NepsCompatConverter: + """A class to convert between NePS configurations and NEPS-compatible configurations. + It provides methods to convert a SamplingResolutionContext to a NEPS-compatible config + and to convert a NEPS-compatible config back to a SamplingResolutionContext. + """ + + _SAMPLING_PREFIX = "SAMPLING__" + _ENVIRONMENT_PREFIX = "ENVIRONMENT__" + _SAMPLING_PREFIX_LEN = len(_SAMPLING_PREFIX) + _ENVIRONMENT_PREFIX_LEN = len(_ENVIRONMENT_PREFIX) + + @dataclasses.dataclass(frozen=True) + class _FromNepsConfigResult: + predefined_samplings: Mapping[str, Any] + environment_values: Mapping[str, Any] + extra_kwargs: Mapping[str, Any] + + @classmethod + def to_neps_config( + cls, + resolution_context: SamplingResolutionContext, + ) -> Mapping[str, Any]: + """Convert a SamplingResolutionContext to a NEPS-compatible config. + + Args: + resolution_context: The SamplingResolutionContext to convert. + + Returns: + A mapping of NEPS-compatible configuration keys to their values. + + Raises: + ValueError: If the resolution_context is not a SamplingResolutionContext. + """ + config: dict[str, Any] = {} + + samplings_made = resolution_context.samplings_made + for sampling_path, value in samplings_made.items(): + config[f"{cls._SAMPLING_PREFIX}{sampling_path}"] = value + + environment_values = resolution_context.environment_values + for env_name, value in environment_values.items(): + config[f"{cls._ENVIRONMENT_PREFIX}{env_name}"] = value + + return config + + @classmethod + def from_neps_config( + cls, + config: Mapping[str, Any], + ) -> _FromNepsConfigResult: + """Convert a NEPS-compatible config to a SamplingResolutionContext. + + Args: + config: A mapping of NEPS-compatible configuration keys to their values. + + Returns: + A _FromNepsConfigResult containing predefined samplings, + environment values, and extra kwargs. + + Raises: + ValueError: If the config is not a valid NEPS-compatible config. + """ + predefined_samplings = {} + environment_values = {} + extra_kwargs = {} + + for name, value in config.items(): + if name.startswith(cls._SAMPLING_PREFIX): + sampling_path = name[cls._SAMPLING_PREFIX_LEN :] + predefined_samplings[sampling_path] = value + elif name.startswith(cls._ENVIRONMENT_PREFIX): + env_name = name[cls._ENVIRONMENT_PREFIX_LEN :] + environment_values[env_name] = value + else: + extra_kwargs[name] = value + + return cls._FromNepsConfigResult( + predefined_samplings=predefined_samplings, + environment_values=environment_values, + extra_kwargs=extra_kwargs, + ) + + +def _prepare_sampled_configs( + chosen_pipelines: list[tuple[PipelineSpace, SamplingResolutionContext]], + n_prev_trials: int, + return_single: bool, # noqa: FBT001 +) -> optimizer.SampledConfig | list[optimizer.SampledConfig]: + configs = [] + for i, (_resolved_pipeline, resolution_context) in enumerate(chosen_pipelines): + neps_config = NepsCompatConverter.to_neps_config( + resolution_context=resolution_context, + ) + + config = optimizer.SampledConfig( + config=neps_config, + id=str(n_prev_trials + i + 1), + previous_config_id=None, + ) + configs.append(config) + + if return_single: + return configs[0] + + return configs + + +def adjust_evaluation_pipeline_for_neps_space( + evaluation_pipeline: Callable, + pipeline_space: P, + operation_converter: Callable[[Operation], Any] = convert_operation_to_callable, +) -> Callable | str: + """Adjust the evaluation pipeline to work with a NePS space. + This function wraps the evaluation pipeline to sample from the NePS space + and convert the sampled pipeline to a format compatible with the evaluation pipeline. + + Args: + evaluation_pipeline: The evaluation pipeline to adjust. + pipeline_space: The NePS pipeline space to sample from. + operation_converter: A callable to convert Operation objects to a format + compatible with the evaluation pipeline. + + Returns: + A wrapped evaluation pipeline that samples from the NePS space. + + Raises: + ValueError: If the evaluation_pipeline is not callable or if the + pipeline_space is not a Pipeline object. + """ + + @functools.wraps(evaluation_pipeline) + def inner(*args: Any, **kwargs: Any) -> Any: + # `kwargs` can contain other things not related to + # the samplings to make or to environment values. + # That is not an issue. Those items will be passed through. + + sampled_pipeline_data = NepsCompatConverter.from_neps_config(config=kwargs) + + sampled_pipeline, _resolution_context = resolve( + pipeline=pipeline_space, + domain_sampler=OnlyPredefinedValuesSampler( + predefined_samplings=sampled_pipeline_data.predefined_samplings, + ), + environment_values=sampled_pipeline_data.environment_values, + ) + + config = dict(**sampled_pipeline.get_attrs()) + + for name, value in config.items(): + if isinstance(value, Operation): + # If the operator is a not a string, we convert it to a callable. + if isinstance(value.operator, str): + config[name] = value.operator + else: + config[name] = operation_converter(value) + + # So that we still pass the kwargs not related to the config, + # start with the extra kwargs we passed to the converter. + new_kwargs = dict(**sampled_pipeline_data.extra_kwargs) + # Then add all the kwargs from the config. + new_kwargs.update(config) + + return evaluation_pipeline(*args, **new_kwargs) + + return inner + + +def convert_neps_to_classic_search_space(space: PipelineSpace) -> SearchSpace | None: + """Convert a NePS space to a classic SearchSpace if possible. + This function checks if the NePS space can be converted to a classic SearchSpace + by ensuring that it does not contain any complex types like Operation or Resampled, + and that all choices of Categorical parameters are of basic types (int, str, float). + If the checks pass, it converts the NePS space to a classic SearchSpace. + + Args: + space: The NePS space to convert, which should be a Pipeline object. + + Returns: + A classic SearchSpace if the conversion is possible, otherwise None. + """ + # First check: No parameters are of type Operation or Resampled + if not any( + isinstance(param, Operation | Resampled) for param in space.get_attrs().values() + ): + # Second check: All choices of all categoricals are of basic + # types i.e. int, str or float + categoricals = [ + param + for param in space.get_attrs().values() + if isinstance(param, Categorical) + ] + if all( + any( + all(isinstance(choice, datatype) for choice in list(cat_param.choices)) # type: ignore + for datatype in [int, float, str] + ) + for cat_param in categoricals + ): + # If both checks pass, convert the space to a classic SearchSpace + classic_space: dict[str, Any] = {} + for key, value in space.get_attrs().items(): + if isinstance(value, Categorical): + classic_space[key] = neps.HPOCategorical( + choices=list(set(value.choices)), # type: ignore + prior=value.choices[value.prior] if value.has_prior else None, # type: ignore + prior_confidence=( + value.prior_confidence.value if value.has_prior else "low" + ), + ) + elif isinstance(value, Integer): + classic_space[key] = neps.HPOInteger( + lower=value.min_value, + upper=value.max_value, + log=value._log if hasattr(value, "_log") else False, + prior=value.prior if value.has_prior else None, + prior_confidence=( + value.prior_confidence.value if value.has_prior else "low" + ), + ) + elif isinstance(value, Float): + classic_space[key] = neps.HPOFloat( + lower=value.min_value, + upper=value.max_value, + log=value._log if hasattr(value, "_log") else False, + prior=value.prior if value.has_prior else None, + prior_confidence=( + value.prior_confidence.value if value.has_prior else "low" + ), + ) + elif isinstance(value, Fidelity): + if isinstance(value._domain, Integer): + classic_space[key] = neps.HPOInteger( + lower=value._domain.min_value, + upper=value._domain.max_value, + log=( + value._domain._log + if hasattr(value._domain, "_log") + else False + ), + is_fidelity=True, + ) + elif isinstance(value._domain, Float): + classic_space[key] = neps.HPOFloat( + lower=value._domain.min_value, + upper=value._domain.max_value, + log=( + value._domain._log + if hasattr(value._domain, "_log") + else False + ), + is_fidelity=True, + ) + else: + classic_space[key] = neps.HPOConstant(value) + return convert_mapping(classic_space) + return None + + +def convert_classic_to_neps_search_space( + space: SearchSpace, +) -> PipelineSpace: + """Convert a classic SearchSpace to a NePS PipelineSpace if possible. + This function converts a classic SearchSpace to a NePS PipelineSpace. + + Args: + space: The classic SearchSpace to convert. + + Returns: + A NePS PipelineSpace. + """ + + class NEPSSpace(PipelineSpace): + """A NePS-specific PipelineSpace.""" + + for parameter_name, parameter in space.elements.items(): + if isinstance(parameter, neps.HPOCategorical): + setattr( + NEPSSpace, + parameter_name, + Categorical( + choices=tuple(parameter.choices), + prior=( + parameter.choices.index(parameter.prior) + if parameter.prior + else _UNSET + ), + prior_confidence=( + parameter.prior_confidence + if parameter.prior_confidence + else _UNSET + ), + ), + ) + elif isinstance(parameter, neps.HPOConstant): + setattr(NEPSSpace, parameter_name, parameter.value) + elif isinstance(parameter, neps.HPOInteger): + new_integer = Integer( + min_value=parameter.lower, + max_value=parameter.upper, + log=parameter.log, + prior=parameter.prior if parameter.prior else _UNSET, + prior_confidence=( + parameter.prior_confidence if parameter.prior_confidence else _UNSET + ), + ) + setattr( + NEPSSpace, + parameter_name, + (Fidelity(domain=new_integer) if parameter.is_fidelity else new_integer), + ) + elif isinstance(parameter, neps.HPOFloat): + new_float = Float( + min_value=parameter.lower, + max_value=parameter.upper, + log=parameter.log, + prior=parameter.prior if parameter.prior else _UNSET, + prior_confidence=( + parameter.prior_confidence if parameter.prior_confidence else _UNSET + ), + ) + setattr( + NEPSSpace, + parameter_name, + (Fidelity(domain=new_float) if parameter.is_fidelity else new_float), + ) + + return NEPSSpace() + + +ONLY_NEPS_ALGORITHMS_NAMES = [ + "neps_random_search", + "neps_priorband", + "complex_random_search", + "neps_hyperband", + "complex_hyperband", +] +CLASSIC_AND_NEPS_ALGORITHMS_NAMES = [ + "random_search", + "priorband", + "hyperband", + "grid_search", +] + + +# Lazy initialization to avoid circular imports +def _get_only_neps_algorithms_functions() -> list[Callable]: + """Get the list of NEPS-only algorithm functions lazily.""" + return [ + algorithms.neps_random_search, + algorithms.neps_priorband, + algorithms.complex_random_search, + algorithms.neps_hyperband, + algorithms.neps_grid_search, + ] + + +def _get_classic_and_neps_algorithms_functions() -> list[Callable]: + """Get the list of classic and NEPS algorithm functions lazily.""" + return [ + algorithms.random_search, + algorithms.priorband, + algorithms.hyperband, + algorithms.grid_search, + ] + + +def check_neps_space_compatibility( + optimizer_to_check: ( + algorithms.OptimizerChoice + | Mapping[str, Any] + | tuple[algorithms.OptimizerChoice, Mapping[str, Any]] + | Callable[ + Concatenate[SearchSpace, ...], optimizer.AskFunction + ] # Hack, while we transit + | Callable[ + Concatenate[PipelineSpace, ...], optimizer.AskFunction + ] # from SearchSpace to + | Callable[ + Concatenate[SearchSpace | PipelineSpace, ...], optimizer.AskFunction + ] # Pipeline + | algorithms.CustomOptimizer + | Literal["auto"] + ) = "auto", +) -> Literal["neps", "classic", "both"]: + """Check if the given optimizer is compatible with a NePS space. + This function checks if the optimizer is a NePS-specific algorithm, + a classic algorithm, or a combination of both. + + Args: + optimizer_to_check: The optimizer to check for compatibility. + It can be a NePS-specific algorithm, a classic algorithm, + or a combination of both. + + Returns: + A string indicating the compatibility: + - "neps" if the optimizer is a NePS-specific algorithm, + - "classic" if the optimizer is a classic algorithm, + - "both" if the optimizer is a combination of both. + """ + inner_optimizer = None + if isinstance(optimizer_to_check, partial): + inner_optimizer = optimizer_to_check.func + while isinstance(inner_optimizer, partial): + inner_optimizer = inner_optimizer.func + + only_neps_algorithm = ( + optimizer_to_check in _get_only_neps_algorithms_functions() + or (inner_optimizer and inner_optimizer in _get_only_neps_algorithms_functions()) + or ( + optimizer_to_check[0] in ONLY_NEPS_ALGORITHMS_NAMES + if isinstance(optimizer_to_check, tuple) + else False + ) + or ( + optimizer_to_check in ONLY_NEPS_ALGORITHMS_NAMES + if isinstance(optimizer_to_check, str) + else False + ) + ) + if only_neps_algorithm: + return "neps" + neps_and_classic_algorithm = ( + optimizer_to_check in _get_classic_and_neps_algorithms_functions() + or ( + inner_optimizer + and inner_optimizer in _get_classic_and_neps_algorithms_functions() + ) + or optimizer_to_check == "auto" + or ( + optimizer_to_check[0] in CLASSIC_AND_NEPS_ALGORITHMS_NAMES + if isinstance(optimizer_to_check, tuple) + else False + ) + or ( + optimizer_to_check in CLASSIC_AND_NEPS_ALGORITHMS_NAMES + if isinstance(optimizer_to_check, str) + else False + ) + ) + if neps_and_classic_algorithm: + return "both" + return "classic" diff --git a/neps/space/neps_spaces/parameters.py b/neps/space/neps_spaces/parameters.py new file mode 100644 index 000000000..2a98cf24c --- /dev/null +++ b/neps/space/neps_spaces/parameters.py @@ -0,0 +1,1653 @@ +"""This module defines various classes and protocols for representing and manipulating +search spaces in NePS (Neural Parameter Search). It includes definitions for domains, +pipelines, operations, and fidelity, as well as utilities for sampling and resolving +search spaces. +""" + +from __future__ import annotations + +import abc +import enum +import math +import random +from collections.abc import Callable, Mapping, Sequence +from typing import Any, Generic, Literal, Protocol, TypeVar, cast, runtime_checkable + +T = TypeVar("T") + + +class _Unset: + def __repr__(self) -> str: + return "" + + +_UNSET = _Unset() + + +def _parameters_are_equivalent(param1: Any, param2: Any) -> bool: + """Check if two parameters are equivalent using their is_equivalent_to method. + + This helper function provides a safe way to compare parameters without + interfering with Python's object identity system. Falls back to regular + equality comparison for objects that don't have the is_equivalent_to method. + + Args: + param1: First parameter to compare. + param2: Second parameter to compare. + + Returns: + True if the parameters are equivalent, False otherwise. + """ + # Try to use the is_equivalent_to method if available + if hasattr(param1, "is_equivalent_to"): + return param1.is_equivalent_to(param2) + if hasattr(param2, "is_equivalent_to"): + return param2.is_equivalent_to(param1) + # Fall back to regular equality for other types + return param1 == param2 + + +@runtime_checkable +class Resolvable(Protocol): + """A protocol for objects that can be resolved into attributes.""" + + def get_attrs(self) -> Mapping[str, Any]: + """Get the attributes of the resolvable object as a mapping.""" + raise NotImplementedError() + + def from_attrs(self, attrs: Mapping[str, Any]) -> Resolvable: + """Create a new resolvable object from the given attributes. + + Args: + attrs: A mapping of attribute names to their values. + + Returns: + A new resolvable object with the specified attributes. + """ + raise NotImplementedError() + + +def resolvable_is_fully_resolved(resolvable: Resolvable) -> bool: + """Check if a resolvable object is fully resolved. + A resolvable object is considered fully resolved if all its attributes are either + not instances of Resolvable or are themselves fully resolved. + + Args: + resolvable: Resolvable: + + Returns: + bool: True if the resolvable object is fully resolved, False otherwise. + """ + attr_objects = resolvable.get_attrs().values() + return all( + not isinstance(obj, Resolvable) or resolvable_is_fully_resolved(obj) + for obj in attr_objects + ) + + +class Fidelity(Resolvable, Generic[T]): + """A class representing a fidelity in a NePS space. + + Attributes: + domain: The domain of the fidelity, which can be an Integer or Float domain. + """ + + def __init__(self, domain: Integer | Float): + """Initialize the Fidelity with a domain. + + Args: + domain: The domain of the fidelity, which can be an Integer or Float domain. + + """ + if domain.has_prior: + raise ValueError( + "The domain of a Fidelity can not have priors, has prior:" + f" {domain.prior!r}." + ) + self._domain = domain + + def __str__(self) -> str: + """Get a string representation of the fidelity.""" + return f"Fidelity({self._domain.__str__()})" + + def is_equivalent_to(self, other: object) -> bool: + """Check if this fidelity parameter is equivalent to another. + + This method provides comparison logic without interfering with Python's + object identity system (unlike __eq__). Use this for functional comparisons + like checking if parameters have the same configuration. + + Args: + other: The object to compare with. + + Returns: + True if the objects are equivalent, False otherwise. + """ + if not isinstance(other, Fidelity): + return False + return self._domain == other._domain + + def __eq__(self, other: object) -> bool: + """Check if this is the exact same object instance. + + This uses object identity to avoid interfering with the resolution caching system. + """ + return self is other + + def __hash__(self) -> int: + """Get hash based on object identity.""" + return id(self) + + @property + def min_value(self) -> int | float: + """Get the minimum value of the fidelity domain. + + Returns: + The minimum value of the fidelity domain. + + """ + return self._domain.min_value + + @property + def max_value(self) -> int | float: + """Get the maximum value of the fidelity domain. + + Returns: + The maximum value of the fidelity domain. + """ + return self._domain.max_value + + def get_attrs(self) -> Mapping[str, Any]: + """Get the attributes of the fidelity as a mapping. + This method collects all attributes of the fidelity class and instance, + excluding private attributes and methods, and returns them as a dictionary. + + Returns: + A mapping of attribute names to their values. + + Raises: + ValueError: If the fidelity has no domain defined. + + """ + raise ValueError("For a Fidelity object there is nothing to resolve.") + + def from_attrs(self, attrs: Mapping[str, Any]) -> Fidelity: # noqa: ARG002 + """Create a new Fidelity instance from the given attributes. + + Args: + attrs: A mapping of attribute names to their values. + + Returns: + A new Fidelity instance with the specified attributes. + + Raises: + ValueError: If the fidelity has no domain defined. + + """ + raise ValueError("For a Fidelity object there is nothing to resolve.") + + +class PipelineSpace(Resolvable): + """A class representing a pipeline in NePS spaces.""" + + @property + def fidelity_attrs(self) -> Mapping[str, Fidelity]: + """Get the fidelity attributes of the pipeline. Fidelity attributes are special + attributes that represent the fidelity of the pipeline. + + Returns: + A mapping of attribute names to Fidelity objects. + """ + return {k: v for k, v in self.get_attrs().items() if isinstance(v, Fidelity)} + + def get_attrs(self) -> Mapping[str, Any]: + """Get the attributes of the pipeline as a mapping. + This method collects all attributes of the pipeline class and instance, + excluding private attributes and methods, and returns them as a dictionary. + + Returns: + A mapping of attribute names to their values. + """ + attrs = {} + + for attr_name, attr_value in vars(self.__class__).items(): + if attr_name.startswith("_") or callable(attr_value) or attr_value is None: + continue + # Skip if this parameter has been marked as removed + attrs[attr_name] = attr_value + + for attr_name, attr_value in vars(self).items(): + if attr_name.startswith("_") or callable(attr_value) or attr_value is None: + continue + attrs[attr_name] = attr_value + + properties_to_ignore = ("fidelity_attrs",) + for property_to_ignore in properties_to_ignore: + attrs.pop(property_to_ignore, None) + + return attrs + + def from_attrs(self, attrs: Mapping[str, Any]) -> PipelineSpace: + """Create a new Pipeline instance from the given attributes. + + Args: + attrs: A mapping of attribute names to their values. + + + Returns: + A new Pipeline instance with the specified attributes. + + Raises: + ValueError: If the attributes do not match the pipeline's expected structure. + """ + new_pipeline = PipelineSpace() + for name, value in attrs.items(): + setattr(new_pipeline, name, value) + return new_pipeline + + def __str__(self) -> str: + """Get a string representation of the pipeline. + + Returns: + A string representation of the pipeline, including its class name and + attributes. + """ + attrs = "\n\t".join( + f"{k} = {v!s}" + for k, v in self.get_attrs().items() + if not k.startswith("_") and not callable(v) + ) + return f"PipelineSpace {self.__class__.__name__} with parameters:\n\t{attrs}" + + def __add__( + self, + other: ( + Integer + | Float + | Categorical + | Operation + | Resampled + | Repeated + | PipelineSpace + ), + name: str | None = None, + ) -> PipelineSpace: + """Add a new parameter to the pipeline. + + Args: + other: The parameter to be added, which can be an Integer, Float, + Categorical, Operation, Resampled, Repeated, or PipelineSpace. + name: The name of the parameter to be added. If None, a default name will be + generated. + + Returns: + A new PipelineSpace instance with the added parameter. + + Raises: + ValueError: If the parameter is not of a supported type or if a parameter + with the same name already exists in the pipeline. + """ + if isinstance(other, PipelineSpace): + new_space = self + for exist_name, value in other.get_attrs().items(): + new_space = new_space.__add__(value, exist_name) + return new_space + + if not isinstance( + other, Integer | Float | Categorical | Operation | Resampled | Repeated + ): + raise ValueError( + "Can only add Integer, Float, Categorical, Operation, Resampled," + f" Repeated or PipelineSpace, got {other!r}." + ) + param_name = name if name else f"param_{len(self.get_attrs()) + 1}" + + class NewSpace(PipelineSpace): + pass + + NewSpace.__name__ = self.__class__.__name__ + + new_pipeline = NewSpace() + for exist_name, value in self.get_attrs().items(): + setattr(new_pipeline, exist_name, value) + if exist_name == param_name and not _parameters_are_equivalent(value, other): + raise ValueError( + f"A different parameter with the name {param_name!r} already exists" + " in the pipeline:\n" + f" {value}\n" + f" {other}" + ) + if not hasattr(new_pipeline, param_name): + setattr(new_pipeline, param_name, other) + return new_pipeline + + def add( + self, + new_param: Integer | Float | Categorical | Operation | Resampled | Repeated, + name: str | None = None, + ) -> PipelineSpace: + """Add a new parameter to the pipeline. This is NOT an in-place operation. + + Args: + new_param: The parameter to be added, which can be an Integer, Float, + Categorical, Operation, Resampled, or Repeated domain. + name: The name of the parameter to be added. If None, a default name will + be generated. + + Returns: + A NEW PipelineSpace with the added parameter. + """ + return self.__add__(new_param, name) + + def remove(self, name: str) -> PipelineSpace: + """Remove a parameter from the pipeline by its name. This is NOT an in-place + operation. + + Args: + name: The name of the parameter to be removed. + + Returns: + A NEW PipelineSpace without the removed parameter. + + Raises: + ValueError: If no parameter with the specified name exists in the pipeline. + """ + if name not in self.get_attrs(): + raise ValueError( + f"No parameter with the name {name!r} exists in the pipeline." + ) + + class NewSpace(PipelineSpace): + pass + + NewSpace.__name__ = self.__class__.__name__ + new_pipeline = NewSpace() + for exist_name, value in self.get_attrs().items(): + if exist_name != name: + setattr(new_pipeline, exist_name, value) + + return new_pipeline + + def add_prior( + self, + parameter_name: str, + prior: Any, + prior_confidence: ConfidenceLevel | Literal["low", "medium", "high"], + ) -> PipelineSpace: + """Add a prior to a parameter in the pipeline. This is NOT an in-place operation. + + Args: + parameter_name: The name of the parameter to which the prior will be added. + prior: The value of the prior to be added. + prior_confidence: The confidence level of the prior, which can be "low", + "medium", or "high". + + Returns: + A NEW PipelineSpace with the added prior. + + Raises: + ValueError: If no parameter with the specified name exists in the pipeline + or if the parameter type does not support priors. + """ + if parameter_name not in self.get_attrs(): + raise ValueError( + f"No parameter with the name {parameter_name!r} exists in the pipeline." + ) + + class NewSpace(PipelineSpace): + pass + + NewSpace.__name__ = self.__class__.__name__ + new_pipeline = NewSpace() + for exist_name, value in self.get_attrs().items(): + if exist_name == parameter_name: + if isinstance(value, Integer | Float | Categorical): + if value.has_prior: + raise ValueError( + f"The parameter {parameter_name!r} already has a prior:" + f" {value.prior!r}." + ) + if isinstance(prior_confidence, str): + prior_confidence = convert_confidence_level(prior_confidence) + old_attributes = dict(value.get_attrs()) + old_attributes["prior"] = prior + old_attributes["prior_confidence"] = prior_confidence + new_value = value.from_attrs(attrs=old_attributes) + else: + raise ValueError( + f"The parameter {parameter_name!r} is of type" + f" {type(value).__name__}, which does not support priors." + ) + else: + new_value = value + setattr(new_pipeline, exist_name, new_value) + return new_pipeline + + +class ConfidenceLevel(enum.Enum): + """Enum representing confidence levels for sampling.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +def convert_confidence_level(confidence: str) -> ConfidenceLevel: + """Convert a string representation of confidence level to ConfidenceLevel enum. + + Args: + confidence: A string representing the confidence level, e.g., "low", "medium", + "high". + + Returns: + ConfidenceLevel: The corresponding ConfidenceLevel enum value. + + Raises: + ValueError: If the input string does not match any of the defined confidence + levels. + """ + try: + return ConfidenceLevel[confidence.upper()] + except KeyError as e: + raise ValueError(f"Invalid confidence level: {confidence}") from e + + +class Domain(Resolvable, abc.ABC, Generic[T]): + """An abstract base class representing a domain in NePS spaces.""" + + @property + @abc.abstractmethod + def min_value(self) -> T: + """Get the minimum value of the domain.""" + raise NotImplementedError() + + @property + @abc.abstractmethod + def max_value(self) -> T: + """Get the maximum value of the domain.""" + raise NotImplementedError() + + @property + @abc.abstractmethod + def has_prior(self) -> bool: + """Check if the domain has a prior defined.""" + raise NotImplementedError() + + @property + @abc.abstractmethod + def prior(self) -> T: + """Get the prior value of the domain. + Raises ValueError if the domain has no prior defined. + + """ + raise NotImplementedError() + + @property + @abc.abstractmethod + def prior_confidence(self) -> ConfidenceLevel: + """Get the confidence level of the prior. + Raises ValueError if the domain has no prior defined. + + """ + raise NotImplementedError() + + @property + @abc.abstractmethod + def range_compatibility_identifier(self) -> str: + """Get a string identifier for the range compatibility of the domain. + This identifier is used to check if two domains are compatible based on their + ranges. + + """ + raise NotImplementedError() + + @abc.abstractmethod + def sample(self) -> T: + """Sample a value from the domain. + Returns a value of type T that is within the domain's range. + + """ + raise NotImplementedError() + + @abc.abstractmethod + def centered_around( + self, + center: T, + confidence: ConfidenceLevel, + ) -> Domain[T]: + """Create a new domain centered around a given value with a specified confidence + level. + + Args: + center: The value around which to center the new domain. + confidence: The confidence level for the new domain. + center: T: + confidence: ConfidenceLevel: + + Returns: + A new Domain instance that is centered around the specified value. + + Raises: + ValueError: If the center value is not within the domain's range. + + """ + raise NotImplementedError() + + def get_attrs(self) -> Mapping[str, Any]: + """Get the attributes of the domain as a mapping. + This method collects all attributes of the domain class and instance, + excluding private attributes and methods, and returns them as a dictionary. + + Returns: + A mapping of attribute names to their values. + """ + return {k.lstrip("_"): v for k, v in vars(self).items()} + + def from_attrs(self, attrs: Mapping[str, Any]) -> Domain[T]: + """Create a new Domain instance from the given attributes. + + Args: + attrs: A mapping of attribute names to their values. + + Returns: + A new Domain instance with the specified attributes. + + Raises: + ValueError: If the attributes do not match the domain's expected structure. + + """ + return type(self)(**attrs) + + +def _calculate_new_domain_bounds( + number_type: type[int] | type[float], + min_value: int | float, + max_value: int | float, + center: int | float, + confidence: ConfidenceLevel, +) -> tuple[int, int] | tuple[float, float]: + """Calculate new bounds for a domain based on a center value and confidence level. + This function determines the new minimum and maximum values for a domain based on + a given center value and a confidence level. It splits the domain range into chunks + and adjusts the bounds based on the specified confidence level. + + Args: + number_type: The type of numbers in the domain (int or float). + min_value: The minimum value of the domain. + max_value: The maximum value of the domain. + center: The center value around which to calculate the new bounds. + confidence: The confidence level for the new bounds. + + Returns: + A tuple containing the new minimum and maximum values for the domain. + + Raises: + ValueError: If the center value is not within the domain's range or if the + number_type is not supported. + """ + if center < min_value or center > max_value: + raise ValueError( + f"Center value {center!r} must be within domain range [{min_value!r}," + f" {max_value!r}]" + ) + + # Determine a chunk size by splitting the domain range into a fixed number of chunks. + # Then use the confidence level to decide how many chunks to include + # around the given center (on each side). + + number_of_chunks = 10.0 + chunk_size = (max_value - min_value) / number_of_chunks + + # The numbers refer to how many segments to have on each side of the center. + # TODO: [lum] we need to make sure that in the end the range does not just have the + # center, but at least a little bit more around it too. + confidence_to_number_of_chunks_on_each_side = { + ConfidenceLevel.HIGH: 1.0, + ConfidenceLevel.MEDIUM: 2.5, + ConfidenceLevel.LOW: 4.0, + } + + chunk_multiplier = confidence_to_number_of_chunks_on_each_side[confidence] + interval_radius = chunk_size * chunk_multiplier + + if number_type is int: + # In this case we need to use ceil/floor so that we end up with ints. + new_min = max(min_value, math.floor(center - interval_radius)) + new_max = min(max_value, math.ceil(center + interval_radius)) + elif number_type is float: + new_min = max(min_value, center - interval_radius) + new_max = min(max_value, center + interval_radius) + else: + raise ValueError(f"Unsupported number type {number_type!r}.") + + return new_min, new_max + + +class Categorical(Domain[int], Generic[T]): + """A domain representing a categorical choice from a set of options. + + Attributes: + choices: A tuple of choices or a Domain of choices. + prior: The index of the prior choice in the choices tuple. + prior_confidence: The confidence level of the prior choice. + """ + + def __init__( + self, + choices: tuple[T | Domain[T] | Resolvable | Any, ...] | Domain[T] | Resolvable, + prior: int | Domain[int] | _Unset = _UNSET, + prior_confidence: ( + ConfidenceLevel | Literal["low", "medium", "high"] | _Unset + ) = _UNSET, + ): + """Initialize the Categorical domain with choices and optional prior. + + Args: + choices: A tuple of choices or a Domain of choices. + prior: The index of the prior choice in the choices tuple. + prior_confidence: The confidence level of the prior choice. + + """ + self._choices: tuple[T | Domain[T] | Resolvable | Any, ...] | Domain[T] + if isinstance(choices, Sequence): + self._choices = tuple(choice for choice in choices) + if any(isinstance(choice, tuple) for choice in self._choices) and any( + not isinstance(choice, tuple) for choice in self._choices + ): + self._choices = tuple( + (choice,) if not isinstance(choice, tuple) else choice + for choice in self._choices + ) + else: + self._choices = choices # type: ignore[assignment] + self._prior = prior + self._prior_confidence = ( + convert_confidence_level(prior_confidence) + if isinstance(prior_confidence, str) + else prior_confidence + ) + if self._prior is not _UNSET and self._prior_confidence is _UNSET: + raise ValueError( + "If prior is set, prior_confidence must also be set to a valid value." + ) + + def __str__(self) -> str: + """Get a string representation of the categorical domain.""" + string = f"Categorical(choices={self._choices!s}" + if self.has_prior: + string += f", prior={self._prior}, prior_confidence={self._prior_confidence}" + string += ")" + return string + + def is_equivalent_to(self, other: object) -> bool: + """Check if this categorical parameter is equivalent to another. + + This method provides comparison logic without interfering with Python's + object identity system (unlike __eq__). Use this for functional comparisons + like checking if parameters have the same configuration. + + Args: + other: The object to compare with. + + Returns: + True if the objects are equivalent, False otherwise. + """ + if not isinstance(other, Categorical): + return False + return ( + self._prior == other._prior + and self._prior_confidence == other._prior_confidence + and self.choices == other.choices + ) + + def __eq__(self, other: object) -> bool: + """Check if this is the exact same object instance. + + This uses object identity to avoid interfering with the resolution caching system. + """ + return self is other + + def __hash__(self) -> int: + """Get hash based on object identity.""" + return id(self) + + @property + def min_value(self) -> int: + """Get the minimum value of the categorical domain. + + Returns: + The minimum index of the choices, which is always 0. + + """ + return 0 + + @property + def max_value(self) -> int: + """Get the maximum value of the categorical domain. + + Returns: + The maximum index of the choices, which is the length of the choices tuple + minus one. + + """ + return max(len(cast(tuple, self._choices)) - 1, 0) + + @property + def choices(self) -> tuple[T | Domain[T] | Resolvable, ...] | Domain[T]: + """Get the choices available in the categorical domain. + + Returns: + A tuple of choices or a Domain of choices. + + """ + return self._choices + + @property + def has_prior(self) -> bool: + """Check if the categorical domain has a prior defined. + + Returns: + True if the prior and prior confidence are set, False otherwise. + """ + return self._prior is not _UNSET and self._prior_confidence is not _UNSET + + @property + def prior(self) -> int: + """Get the prior index of the categorical domain. + + Returns: + The index of the prior choice in the choices tuple. + + Raises: + ValueError: If the domain has no prior defined. + + """ + if not self.has_prior: + raise ValueError("Domain has no prior and prior_confidence defined.") + return int(cast(int, self._prior)) + + @property + def prior_confidence(self) -> ConfidenceLevel: + """Get the confidence level of the prior choice. + + Returns: + The confidence level of the prior choice. + + Raises: + ValueError: If the domain has no prior defined. + + """ + if not self.has_prior: + raise ValueError("Domain has no prior and prior_confidence defined.") + return cast(ConfidenceLevel, self._prior_confidence) + + @property + def range_compatibility_identifier(self) -> str: + """Get a string identifier for the range compatibility of the categorical domain. + + Returns: + A string representation of the number of choices in the domain. + + """ + return f"{len(cast(tuple, self._choices))}" + + def sample(self) -> int: + """Sample a random index from the categorical choices. + + Returns: + A randomly selected index from the choices tuple. + + Raises: + ValueError: If the choices are empty. + + """ + return int(random.randint(0, len(cast(tuple[T], self._choices)) - 1)) + + def centered_around( + self, + center: int, + confidence: ConfidenceLevel, + ) -> Categorical: + """Create a new categorical domain centered around a specific choice index. + + Args: + center: The index of the choice around which to center the new domain. + confidence: The confidence level for the new domain. + center: int: + confidence: ConfidenceLevel: + + Returns: + A new Categorical instance with a range centered around the specified + choice index. + + Raises: + ValueError: If the center index is out of bounds of the choices. + + """ + new_min, new_max = cast( + tuple[int, int], + _calculate_new_domain_bounds( + number_type=int, + min_value=self.min_value, + max_value=self.max_value, + center=center, + confidence=confidence, + ), + ) + new_choices = cast(tuple, self._choices)[new_min : new_max + 1] + return Categorical( + choices=new_choices, + prior=new_choices.index(cast(tuple, self._choices)[center]), + prior_confidence=confidence, + ) + + +class Float(Domain[float]): + """A domain representing a continuous range of floating-point values. + + Attributes: + min_value: The minimum value of the domain. + max_value: The maximum value of the domain. + log: Whether to sample values on a logarithmic scale. + prior: The prior value for the domain, if any. + prior_confidence: The confidence level of the prior value. + """ + + def __init__( + self, + min_value: float, + max_value: float, + log: bool = False, # noqa: FBT001, FBT002 + prior: float | _Unset = _UNSET, + prior_confidence: ( + Literal["low", "medium", "high"] | ConfidenceLevel | _Unset + ) = _UNSET, + ): + """Initialize the Float domain with min and max values, and optional prior. + + Args: + min_value: The minimum value of the domain. + max_value: The maximum value of the domain. + log: Whether to sample values on a logarithmic scale. + prior: The prior value for the domain, if any. + prior_confidence: The confidence level of the prior value. + + """ + self._min_value = min_value + self._max_value = max_value + self._log = log + self._prior = prior + self._prior_confidence = ( + convert_confidence_level(prior_confidence) + if isinstance(prior_confidence, str) + else prior_confidence + ) + if self._prior is not _UNSET and self._prior_confidence is _UNSET: + raise ValueError( + "If prior is set, prior_confidence must also be set to a valid value." + ) + + def __str__(self) -> str: + """Get a string representation of the floating-point domain.""" + string = f"Float({self._min_value}, {self._max_value}" + if self._log: + string += ", log" + if self.has_prior: + string += f", prior={self._prior}, prior_confidence={self._prior_confidence}" + string += ")" + return string + + def is_equivalent_to(self, other: object) -> bool: + """Check if this float parameter is equivalent to another. + + This method provides comparison logic without interfering with Python's + object identity system (unlike __eq__). Use this for functional comparisons + like checking if parameters have the same configuration. + + Args: + other: The object to compare with. + + Returns: + True if the objects are equivalent, False otherwise. + """ + if not isinstance(other, Float): + return False + return ( + self._prior == other._prior + and self._prior_confidence == other._prior_confidence + and self.min_value == other.min_value + and self.max_value == other.max_value + and self._log == other._log + ) + + def __eq__(self, other: object) -> bool: + """Check if this is the exact same object instance. + + This uses object identity to avoid interfering with the resolution caching system. + """ + return self is other + + def __hash__(self) -> int: + """Get hash based on object identity.""" + return id(self) + + @property + def min_value(self) -> float: + """Get the minimum value of the floating-point domain. + + Returns: + The minimum value of the domain. + + Raises: + ValueError: If min_value is greater than max_value. + + """ + return self._min_value + + @property + def max_value(self) -> float: + """Get the maximum value of the floating-point domain. + + Returns: + The maximum value of the domain. + + Raises: + ValueError: If min_value is greater than max_value. + + """ + return self._max_value + + @property + def has_prior(self) -> bool: + """Check if the floating-point domain has a prior defined. + + Returns: + True if the prior and prior confidence are set, False otherwise. + + """ + return self._prior is not _UNSET and self._prior_confidence is not _UNSET + + @property + def prior(self) -> float: + """Get the prior value of the floating-point domain. + + Returns: + The prior value of the domain. + + Raises: + ValueError: If the domain has no prior defined. + + """ + if not self.has_prior: + raise ValueError("Domain has no prior and prior_confidence defined.") + return float(cast(float, self._prior)) + + @property + def prior_confidence(self) -> ConfidenceLevel: + """Get the confidence level of the prior value. + + Returns: + The confidence level of the prior value. + + Raises: + ValueError: If the domain has no prior defined. + + """ + if not self.has_prior: + raise ValueError("Domain has no prior and prior_confidence defined.") + return cast(ConfidenceLevel, self._prior_confidence) + + @property + def range_compatibility_identifier(self) -> str: + """Get a string identifier for the range compatibility of the floating-point + domain. + + Returns: + A string representation of the minimum and maximum values, and whether + the domain is logarithmic. + + """ + return f"{self._min_value}_{self._max_value}_{self._log}" + + def sample(self) -> float: + """Sample a random floating-point value from the domain. + + Returns: + A randomly selected floating-point value within the domain's range. + + Raises: + ValueError: If min_value is greater than max_value. + + """ + if self._log: + log_min = math.log(self._min_value) + log_max = math.log(self._max_value) + return float(math.exp(random.uniform(log_min, log_max))) + return float(random.uniform(self._min_value, self._max_value)) + + def centered_around( + self, + center: float, + confidence: ConfidenceLevel, + ) -> Float: + """Create a new floating-point domain centered around a specific value. + + Args: + center: The value around which to center the new domain. + confidence: The confidence level for the new domain. + center: float: + confidence: ConfidenceLevel: + + Returns: + A new Float instance that is centered around the specified value. + + Raises: + ValueError: If the center value is not within the domain's range. + + """ + new_min, new_max = _calculate_new_domain_bounds( + number_type=float, + min_value=self.min_value, + max_value=self.max_value, + center=center, + confidence=confidence, + ) + return Float( + min_value=new_min, + max_value=new_max, + log=self._log, + prior=center, + prior_confidence=confidence, + ) + + +class Integer(Domain[int]): + """A domain representing a range of integer values. + + Attributes: + min_value: The minimum value of the domain. + max_value: The maximum value of the domain. + log: Whether to sample values on a logarithmic scale. + prior: The prior value for the domain, if any. + prior_confidence: The confidence level of the prior value. + """ + + def __init__( + self, + min_value: int, + max_value: int, + log: bool = False, # noqa: FBT001, FBT002 + prior: float | int | _Unset = _UNSET, + prior_confidence: ( + Literal["low", "medium", "high"] | ConfidenceLevel | _Unset + ) = _UNSET, + ): + """Initialize the Integer domain with min and max values, and optional prior. + + Args: + min_value: The minimum value of the domain. + max_value: The maximum value of the domain. + log: Whether to sample values on a logarithmic scale. + prior: The prior value for the domain, if any. + prior_confidence: The confidence level of the prior value. + """ + self._min_value = min_value + self._max_value = max_value + self._log = log + self._prior = prior + self._prior_confidence = ( + convert_confidence_level(prior_confidence) + if isinstance(prior_confidence, str) + else prior_confidence + ) + if self._prior != _UNSET and self._prior_confidence is _UNSET: + raise ValueError( + "If prior is set, prior_confidence must also be set to a valid value." + ) + + def __str__(self) -> str: + """Get a string representation of the integer domain.""" + string = f"Integer({self._min_value}, {self._max_value}" + if self._log: + string += ", log" + if self.has_prior: + string += f", prior={self._prior}, prior_confidence={self._prior_confidence}" + string += ")" + return string + + def is_equivalent_to(self, other: object) -> bool: + """Check if this integer parameter is equivalent to another. + + This method provides comparison logic without interfering with Python's + object identity system (unlike __eq__). Use this for functional comparisons + like checking if parameters have the same configuration. + + Args: + other: The object to compare with. + + Returns: + True if the objects are equivalent, False otherwise. + """ + if not isinstance(other, Integer): + return False + return ( + self._prior == other._prior + and self._prior_confidence == other._prior_confidence + and self.min_value == other.min_value + and self.max_value == other.max_value + and self._log == other._log + ) + + def __eq__(self, other: object) -> bool: + """Check if this is the exact same object instance. + + This uses object identity to avoid interfering with the resolution caching system. + """ + return self is other + + def __hash__(self) -> int: + """Get hash based on object identity.""" + return id(self) + + @property + def min_value(self) -> int: + """Get the minimum value of the integer domain. + + Returns: + The minimum value of the domain. + + Raises: + ValueError: If min_value is greater than max_value. + + """ + return self._min_value + + @property + def max_value(self) -> int: + """Get the maximum value of the integer domain. + + Returns: + The maximum value of the domain. + + Raises: + ValueError: If min_value is greater than max_value. + + """ + return self._max_value + + @property + def has_prior(self) -> bool: + """Check if the integer domain has a prior defined. + + Returns: + True if the prior and prior confidence are set, False otherwise. + + """ + return self._prior is not _UNSET and self._prior_confidence is not _UNSET + + @property + def prior(self) -> int: + """Get the prior value of the integer domain. + + Returns: + The prior value of the domain. + + Raises: + ValueError: If the domain has no prior defined. + + """ + if not self.has_prior: + raise ValueError("Domain has no prior and prior_confidence defined.") + return int(cast(int, self._prior)) + + @property + def prior_confidence(self) -> ConfidenceLevel: + """Get the confidence level of the prior value. + + Returns: + The confidence level of the prior value. + + Raises: + ValueError: If the domain has no prior defined. + + """ + if not self.has_prior: + raise ValueError("Domain has no prior and prior_confidence defined.") + return cast(ConfidenceLevel, self._prior_confidence) + + @property + def range_compatibility_identifier(self) -> str: + """Get a string identifier for the range compatibility of the integer domain. + + Returns: + A string representation of the minimum and maximum values, and whether + the domain is logarithmic. + + """ + return f"{self._min_value}_{self._max_value}_{self._log}" + + def sample(self) -> int: + """Sample a random integer value from the domain. + + Returns: + A randomly selected integer value within the domain's range. + + """ + if self._log: + return int( + math.exp( + random.uniform(math.log(self._min_value), math.log(self._max_value)) + ) + ) + return int(random.randint(self._min_value, self._max_value)) + + def centered_around( + self, + center: int, + confidence: ConfidenceLevel, + ) -> Integer: + """Create a new integer domain centered around a specific value. + + Args: + center: The value around which to center the new domain. + confidence: The confidence level for the new domain. + center: int: + confidence: ConfidenceLevel: + + Returns: + A new Integer instance that is centered around the specified value. + + Raises: + ValueError: If the center value is not within the domain's range. + + """ + new_min, new_max = cast( + tuple[int, int], + _calculate_new_domain_bounds( + number_type=int, + min_value=self.min_value, + max_value=self.max_value, + center=center, + confidence=confidence, + ), + ) + return Integer( + min_value=new_min, + max_value=new_max, + log=self._log, + prior=center, + prior_confidence=confidence, + ) + + +class Operation(Resolvable): + """A class representing an operation in a NePS space. + + Attributes: + operator: The operator to be used in the operation, can be a callable or a string. + args: A sequence of arguments to be passed to the operator. + kwargs: A mapping of keyword arguments to be passed to the operator. + """ + + def __init__( + self, + operator: Callable | str, + args: Sequence[Any] | Resolvable | None = None, + kwargs: Mapping[str, Any] | Resolvable | None = None, + ): + """Initialize the Operation with an operator, arguments, and keyword arguments. + + Args: + operator: The operator to be used in the operation, can be a callable or a + string. + args: A sequence of arguments to be passed to the operator. + kwargs: A mapping of keyword arguments to be passed to the operator. + + """ + self._operator = operator + + self._args: tuple[Any, ...] | Resolvable + if not isinstance(args, Resolvable): + self._args = tuple(args) if args else () + else: + self._args = args + + self._kwargs: Mapping[str, Any] | Resolvable + if not isinstance(kwargs, Resolvable): + self._kwargs = kwargs if kwargs else {} + else: + self._kwargs = kwargs + + def __str__(self) -> str: + """Get a string representation of the operation.""" + return ( + f"Operation(operator={self._operator!s}, args={self._args!s}," + f" kwargs={self._kwargs!s})" + ) + + def is_equivalent_to(self, other: object) -> bool: + """Check if this operation parameter is equivalent to another. + + This method provides comparison logic without interfering with Python's + object identity system (unlike __eq__). Use this for functional comparisons + like checking if parameters have the same configuration. + + Args: + other: The object to compare with. + + Returns: + True if the objects are equivalent, False otherwise. + """ + if not isinstance(other, Operation): + return False + return ( + self.operator == other.operator + and self.args == other.args + and self.kwargs == other.kwargs + ) + + def __eq__(self, other: object) -> bool: + """Check if this is the exact same object instance. + + This uses object identity to avoid interfering with the resolution caching system. + """ + return self is other + + def __hash__(self) -> int: + """Get hash based on object identity.""" + return id(self) + + @property + def operator(self) -> Callable | str: + """Get the operator of the operation. + + Returns: + The operator, which can be a callable or a string. + + Raises: + ValueError: If the operator is not callable or a string. + + """ + return self._operator + + @property + def args(self) -> tuple[Any, ...]: + """Get the arguments of the operation. + + Returns: + A tuple of arguments to be passed to the operator. + + Raises: + ValueError: If the args are not resolved to a tuple. + """ + if isinstance(self._args, Resolvable): + raise ValueError( + f"Operation args contain unresolved Resolvable: {self._args!r}. " + "The operation needs to be resolved before accessing args as a tuple." + ) + return self._args + + @property + def kwargs(self) -> Mapping[str, Any]: + """Get the keyword arguments of the operation. + + Returns: + A mapping of keyword arguments to be passed to the operator. + + Raises: + ValueError: If the kwargs are not resolved to a mapping. + """ + if isinstance(self._kwargs, Resolvable): + raise ValueError( + f"Operation kwargs contain unresolved Resolvable: {self._kwargs!r}. " + "The operation needs to be resolved before accessing kwargs as a mapping." + ) + return self._kwargs + + def get_attrs(self) -> Mapping[str, Any]: + """Get the attributes of the operation as a mapping. + This method collects all attributes of the operation class and instance, + excluding private attributes and methods, and returns them as a dictionary. + + Returns: + A mapping of attribute names to their values. + + """ + return {k.lstrip("_"): v for k, v in vars(self).items()} + + def from_attrs(self, attrs: Mapping[str, Any]) -> Operation: + """Create a new Operation instance from the given attributes. + + Args: + attrs: A mapping of attribute names to their values. + + Returns: + A new Operation instance with the specified attributes. + + Raises: + ValueError: If the attributes do not match the operation's expected structure. + + """ + return type(self)(**attrs) + + +# TODO: [lum] For tuples, lists and dicts, +# should we make the behavior similar to other resolvables, +# in that they will be cached and then we also need to use Resampled for them? + + +class Resampled(Resolvable): + """A class representing a resampling operation in a NePS space. + + Attributes: + source: The source of the resampling, which can be a resolvable object or a + string. + """ + + def __init__(self, source: Resolvable | str): + """Initialize the Resampled object with a source. + + Args: + source: The source of the resampling, can be a resolvable object or a string. + """ + self._source = source + + def __str__(self) -> str: + return f"Resampled({self._source!s})" + + @property + def source(self) -> Resolvable | str: + """Get the source of the resampling. + + Returns: + The source of the resampling, which can be a resolvable object or a string + + """ + return self._source + + @property + def is_resampling_by_name(self) -> bool: + """Check if the resampling is by name. + + Returns: + True if the source is a string, indicating a resampling by name, + False if the source is a resolvable object. + + """ + return isinstance(self._source, str) + + def get_attrs(self) -> Mapping[str, Any]: + """Get the attributes of the resampling source as a mapping. + + Returns: + A mapping of attribute names to their values. + + Raises: + ValueError: If the resampling is by name or the source is not resolvable. + + """ + if self.is_resampling_by_name: + raise ValueError( + f"This is a resampling by name, can't get attrs from it: {self.source!r}." + ) + if not isinstance(self._source, Resolvable): + raise ValueError( + f"Source should be a resolvable object. Is: {self._source!r}." + ) + return self._source.get_attrs() + + def from_attrs(self, attrs: Mapping[str, Any]) -> Resolvable: + """Create a new resolvable object from the given attributes. + + Args: + attrs: A mapping of attribute names to their values. + + Returns: + A new resolvable object created from the specified attributes. + + Raises: + ValueError: If the resampling is by name or the source is not resolvable. + + """ + if self.is_resampling_by_name: + raise ValueError( + "This is a resampling by name, can't create object for it:" + f" {self.source!r}." + ) + if not isinstance(self._source, Resolvable): + raise ValueError( + f"Source should be a resolvable object. Is: {self._source!r}." + ) + return self._source.from_attrs(attrs) + + +class Repeated(Resolvable): + """A class representing a sequence where a resolvable + is repeated a variable number of times. + + Attributes: + count: The count how many times the content should be repeated. + content: The content which will be repeated. + """ + + def __init__( + self, + count: int | Domain[int] | Resolvable, + content: Resolvable | Any, + ): + """Initialize the Repeated object with a count and content. + + Args: + count: The count how many times the content should be repeated. + content: The content which will be repeated. + """ + if isinstance(count, int) and count < 0: + raise ValueError(f"The received repeat count is negative. Received {count!r}") + + self._count = count + self._content = content + + @property + def count(self) -> int | Domain[int] | Resolvable: + """Get the count how many times the content should be repeated. + + Returns: + The count how many times the content will be repeated. + """ + return self._count + + @property + def content(self) -> Resolvable | Any: + """Get the content which will be repeated. + + Returns: + The content which will be repeated. + """ + return self._content + + def get_attrs(self) -> Mapping[str, Any]: + """Get the attributes of the resolvable as a mapping. + + Returns: + A mapping of attribute names to their values. + """ + return {"count": self.count, "content": self.content} + + def from_attrs(self, attrs: Mapping[str, Any]) -> Resolvable: + """Create a new resolvable object from the given attributes. + + Args: + attrs: A mapping of attribute names to their values. + + Returns: + A new resolvable object created from the specified attributes. + """ + return Repeated(count=attrs["count"], content=attrs["content"]) + + +class Lazy(Resolvable): + """A class representing a lazy operation in a NePS space. + + The purpose is to have the resolution process + stop at the moment it gets to this object, + preventing the resolution of the object it wraps. + + Attributes: + content: The content held, which can be a resolvable object or a + tuple or a string. + """ + + def __init__(self, content: Resolvable | tuple[Any] | str): + """Initialize the Lazy object with content. + + Args: + content: The content being held, which can be a resolvable object + or a tuple or a string. + """ + self._content = content + + @property + def content(self) -> Resolvable | tuple[Any] | str: + """Get the content being held. + + Returns: + The content of the lazy resolvable, which can be a resolvable object + or a tuple or a string. + """ + return self._content + + def get_attrs(self) -> Mapping[str, Any]: + """Get the attributes of the lazy resolvable as a mapping. + + Raises: + ValueError: Always, since this operation does not make sense here. + """ + raise ValueError( + f"This is a lazy resolvable. Can't get attrs from it: {self.content!r}." + ) + + def from_attrs(self, attrs: Mapping[str, Any]) -> Resolvable: # noqa: ARG002 + """Create a new resolvable object from the given attributes. + + Args: + attrs: A mapping of attribute names to their values. + + Returns: + A new resolvable object created from the specified attributes. + + Raises: + ValueError: Always, since this operation does not make sense here. + + + """ + raise ValueError( + f"This is a lazy resolvable. Can't create object for it: {self.content!r}." + ) + + +# TODO: [lum] all the `get_attrs` and `from_attrs` MUST NOT raise. +# They should return the best representation of themselves that they can. +# This is because all resolvable objects can be nested content of other +# resolvable objects that in general will interact with them +# through these two methods. +# When they raise, then the traversal will not be possible. diff --git a/neps/space/neps_spaces/sampling.py b/neps/space/neps_spaces/sampling.py new file mode 100644 index 000000000..b67465c99 --- /dev/null +++ b/neps/space/neps_spaces/sampling.py @@ -0,0 +1,531 @@ +"""This module defines various samplers for NEPS spaces, allowing for different sampling +strategies such as predefined values, random sampling, and mutation-based sampling. +""" + +from __future__ import annotations + +import random +from collections.abc import Mapping +from typing import Any, Protocol, TypeVar, cast, runtime_checkable + +from scipy import stats + +from neps.sampling.priors import PRIOR_CONFIDENCE_MAPPING +from neps.space.neps_spaces.parameters import ( + Categorical, + ConfidenceLevel, + Domain, + Float, + Integer, + PipelineSpace, +) + +T = TypeVar("T") +P = TypeVar("P", bound="PipelineSpace") + + +@runtime_checkable +class DomainSampler(Protocol): + """A protocol for domain samplers that can sample from a given domain.""" + + def __call__( + self, + *, + domain_obj: Domain[T], + current_path: str, + ) -> T: + """Sample a value from the given domain. + + Args: + domain_obj: The domain object to sample from. + current_path: The current path in the resolution context. + + Returns: + A sampled value of type T from the domain. + + Raises: + NotImplementedError: If the method is not implemented. + """ + raise NotImplementedError() + + +class OnlyPredefinedValuesSampler(DomainSampler): + """A sampler that only returns predefined values for a given path. + If the path is not found in the predefined values, it raises a ValueError. + + Args: + predefined_samplings: A mapping of paths to predefined values. + """ + + def __init__( + self, + predefined_samplings: Mapping[str, Any], + ): + """Initialize the sampler with predefined samplings. + + Args: + predefined_samplings: A mapping of paths to predefined values. + + Raises: + ValueError: If predefined_samplings is empty. + """ + self._predefined_samplings = predefined_samplings + + def __call__( + self, + *, + domain_obj: Domain[T], # noqa: ARG002 + current_path: str, + ) -> T: + """Sample a value from the predefined samplings for the given path. + + Args: + domain_obj: The domain object, not used in this sampler. + current_path: The path for which to sample a value. + + Returns: + The predefined value for the given path. + + Raises: + ValueError: If the current path is not in the predefined samplings. + """ + if current_path not in self._predefined_samplings: + raise ValueError(f"No predefined value for path: {current_path!r}.") + return cast(T, self._predefined_samplings[current_path]) + + +class RandomSampler(DomainSampler): + """A sampler that randomly samples from a predefined set of values. + If the current path is not in the predefined values, it samples from the domain. + + Args: + predefined_samplings: A mapping of paths to predefined values. + This sampler will use these values if available, otherwise it will sample + from the domain. + """ + + def __init__( + self, + predefined_samplings: Mapping[str, Any], + ): + """Initialize the sampler with predefined samplings. + + Args: + predefined_samplings: A mapping of paths to predefined values. + + Raises: + ValueError: If predefined_samplings is empty. + """ + self._predefined_samplings = predefined_samplings + + def __call__( + self, + *, + domain_obj: Domain[T], + current_path: str, + ) -> T: + """Sample a value from the predefined samplings or the domain. + + Args: + domain_obj: The domain object from which to sample. + current_path: The path for which to sample a value. + + Returns: + A sampled value, either from the predefined samplings or from the + domain. + + Raises: + ValueError: If the current path is not in the predefined samplings and + the domain does not have a prior defined. + """ + if current_path not in self._predefined_samplings: + sampled_value = domain_obj.sample() + else: + sampled_value = cast(T, self._predefined_samplings[current_path]) + return sampled_value + + +class PriorOrFallbackSampler(DomainSampler): + """A sampler that uses a prior value if available, otherwise falls back to another + sampler. + + Args: + fallback_sampler: A DomainSampler to use if the prior is not available. + always_use_prior: If True, always use the prior value when available. + """ + + def __init__( + self, + fallback_sampler: DomainSampler, + always_use_prior: bool = False, # noqa: FBT001, FBT002 + ): + """Initialize the sampler with a fallback sampler and a flag to always use the + prior. + + Args: + fallback_sampler: A DomainSampler to use if the prior is not available. + always_use_prior: If True, always use the prior value when available. + """ + self._fallback_sampler = fallback_sampler + self._always_use_prior = always_use_prior + + def __call__( + self, + *, + domain_obj: Domain[T], + current_path: str, + ) -> T: + """Sample a value from the domain, using the prior if available and according to + the prior confidence probability. + + Args: + domain_obj: The domain object from which to sample. + current_path: The path for which to sample a value. + + Returns: + A sampled value, either from the prior or from the fallback sampler. + + Raises: + ValueError: If the domain does not have a prior defined and the fallback + sampler is not provided. + """ + if domain_obj.has_prior: + _prior_probability = PRIOR_CONFIDENCE_MAPPING.get( + domain_obj.prior_confidence.value, 0.5 + ) + if isinstance(domain_obj, Categorical) or self._always_use_prior: + if ( + random.choices( + (True, False), + weights=(_prior_probability, 1 - _prior_probability), + k=1, + )[0] + or self._always_use_prior + ): + # If the prior is defined, we sample from it. + return domain_obj.prior + + # For Integers and Floats, sample gaussians around the prior + + elif isinstance(domain_obj, Integer | Float): + # Sample an integer from a Gaussian distribution centered around the + # prior, cut of the tails to ensure the value is within the domain's + # range. Using the _prior_probability to determine the standard deviation + assert hasattr(domain_obj, "min_value") + assert hasattr(domain_obj, "max_value") + assert hasattr(domain_obj, "prior") + + std_dev = 1 / ( + 10 + * _prior_probability + / (domain_obj.max_value - domain_obj.min_value) # type: ignore + ) + + a = (domain_obj.min_value - domain_obj.prior) / std_dev # type: ignore + b = (domain_obj.max_value - domain_obj.prior) / std_dev # type: ignore + sampled_value = stats.truncnorm.rvs( + a=a, + b=b, + loc=domain_obj.prior, # type: ignore + scale=std_dev, + ) + if isinstance(domain_obj, Integer): + sampled_value = int(round(sampled_value)) + else: + sampled_value = float(sampled_value) # type: ignore + return cast(T, sampled_value) + + return self._fallback_sampler( + domain_obj=domain_obj, + current_path=current_path, + ) + + +def _mutate_samplings_to_make_by_forgetting( + samplings_to_make: Mapping[str, Any], + n_forgets: int, +) -> Mapping[str, Any]: + mutated_samplings_to_make = dict(**samplings_to_make) + + samplings_to_delete = random.sample( + list(samplings_to_make.keys()), + k=n_forgets, + ) + + for choice_to_delete in samplings_to_delete: + mutated_samplings_to_make.pop(choice_to_delete) + + return mutated_samplings_to_make + + +class MutateByForgettingSampler(DomainSampler): + """A sampler that mutates predefined samplings by forgetting a certain number of + them. It randomly selects a number of predefined samplings to forget and returns a + new sampler that only uses the remaining samplings. + + Args: + predefined_samplings: A mapping of paths to predefined values. + n_forgets: The number of predefined samplings to forget. + This should be an integer greater than 0 and less than or equal to the + number of predefined samplings. + + Raises: + ValueError: If n_forgets is not a valid integer or if it exceeds the number + of predefined samplings. + """ + + def __init__( + self, + predefined_samplings: Mapping[str, Any], + n_forgets: int, + ): + """Initialize the sampler with predefined samplings and a number of forgets. + + Args: + predefined_samplings: A mapping of paths to predefined values. + n_forgets: The number of predefined samplings to forget. + This should be an integer greater than 0 and less than or equal to the + number of predefined samplings. + + Raises: + ValueError: If n_forgets is not a valid integer or if it exceeds the + number of predefined samplings. + """ + if ( + not isinstance(n_forgets, int) + or n_forgets <= 0 + or n_forgets > len(predefined_samplings) + ): + raise ValueError(f"Invalid value for `n_forgets`: {n_forgets!r}.") + + mutated_samplings_to_make = _mutate_samplings_to_make_by_forgetting( + samplings_to_make=predefined_samplings, + n_forgets=n_forgets, + ) + + self._random_sampler = RandomSampler( + predefined_samplings=mutated_samplings_to_make, + ) + + def __call__( + self, + *, + domain_obj: Domain[T], + current_path: str, + ) -> T: + """Sample a value from the mutated predefined samplings or the domain. + + Args: + domain_obj: The domain object from which to sample. + current_path: The path for which to sample a value. + + Returns: + A sampled value, either from the mutated predefined samplings or from + the domain. + + Raises: + ValueError: If the current path is not in the mutated predefined + samplings and the domain does not have a prior defined. + """ + return self._random_sampler(domain_obj=domain_obj, current_path=current_path) + + +class MutatateUsingCentersSampler(DomainSampler): + """A sampler that mutates predefined samplings by forgetting a certain number of them, + but still uses the original values as centers for sampling. + + Args: + predefined_samplings: A mapping of paths to predefined values. + n_mutations: The number of predefined samplings to mutate. + This should be an integer greater than 0 and less than or equal to the number + of predefined samplings. + + Raises: + ValueError: If n_mutations is not a valid integer or if it exceeds the number + of predefined samplings. + """ + + def __init__( + self, + predefined_samplings: Mapping[str, Any], + n_mutations: int, + ): + """Initialize the sampler with predefined samplings and a number of mutations. + + Args: + predefined_samplings: A mapping of paths to predefined values. + n_mutations: The number of predefined samplings to mutate. + This should be an integer greater than 0 and less than or equal to the + number of predefined samplings. + + Raises: + ValueError: If n_mutations is not a valid integer or if it exceeds + the number of predefined samplings. + """ + if ( + not isinstance(n_mutations, int) + or n_mutations <= 0 + or n_mutations > len(predefined_samplings) + ): + raise ValueError(f"Invalid value for `n_mutations`: {n_mutations!r}.") + + self._kept_samplings_to_make = _mutate_samplings_to_make_by_forgetting( + samplings_to_make=predefined_samplings, + n_forgets=n_mutations, + ) + + # Still remember the original choices. We'll use them as centers later. + self._original_samplings_to_make = predefined_samplings + + def __call__( + self, + *, + domain_obj: Domain[T], + current_path: str, + ) -> T: + """Sample a value from the predefined samplings or the domain, using original + values as centers if the current path is not in the kept samplings. + + Args: + domain_obj: The domain object from which to sample. + current_path: The path for which to sample a value. + + Returns: + A sampled value, either from the kept samplings or from the domain, + using the original values as centers if necessary. + + Raises: + ValueError: If the current path is not in the kept samplings and the + domain does not have a prior defined. + """ + if current_path not in self._kept_samplings_to_make: + # For this path we either have forgotten the value or we never had it. + if current_path in self._original_samplings_to_make: + # We had a value for this path originally, use it as a center. + original_value = self._original_samplings_to_make[current_path] + sampled_value = domain_obj.centered_around( + center=original_value, + confidence=ConfidenceLevel.HIGH, + ).sample() + else: + # We never had a value for this path, we can only sample from the domain. + sampled_value = domain_obj.sample() + else: + # For this path we have chosen to keep the original value. + sampled_value = cast(T, self._kept_samplings_to_make[current_path]) + + return sampled_value + + +class CrossoverNotPossibleError(Exception): + """Exception raised when a crossover operation is not possible.""" + + +def _crossover_samplings_to_make_by_mixing( + predefined_samplings_1: Mapping[str, Any], + predefined_samplings_2: Mapping[str, Any], + prefer_first_probability: float, +) -> tuple[bool, Mapping[str, Any]]: + crossed_over_samplings = dict(**predefined_samplings_1) + made_any_crossovers = False + + for path, sampled_value_in_2 in predefined_samplings_2.items(): + if path in crossed_over_samplings: + use_value_from_2 = random.choices( + (False, True), + weights=(prefer_first_probability, 1 - prefer_first_probability), + k=1, + )[0] + if use_value_from_2: + crossed_over_samplings[path] = sampled_value_in_2 + made_any_crossovers = True + else: + crossed_over_samplings[path] = sampled_value_in_2 + + return made_any_crossovers, crossed_over_samplings + + +class CrossoverByMixingSampler(DomainSampler): + """A sampler that performs a crossover operation by mixing two sets of predefined + samplings. It combines the predefined samplings from two sources, allowing for a + probability-based selection of values from either source. + + Args: + predefined_samplings_1: The first set of predefined samplings. + predefined_samplings_2: The second set of predefined samplings. + prefer_first_probability: The probability of preferring values from the first + set over the second set when both have values for the same path. + This should be a float between 0 and 1, where 0 means always prefer the + second set and 1 means always prefer the first set. + + Raises: + ValueError: If prefer_first_probability is not between 0 and 1. + CrossoverNotPossibleError: If no crossovers were made between the two sets + of predefined samplings. + """ + + def __init__( + self, + predefined_samplings_1: Mapping[str, Any], + predefined_samplings_2: Mapping[str, Any], + prefer_first_probability: float, + ): + """Initialize the sampler with two sets of predefined samplings and a preference + probability for the first set. + + Args: + predefined_samplings_1: The first set of predefined samplings. + predefined_samplings_2: The second set of predefined samplings. + prefer_first_probability: The probability of preferring values from the + first set over the second set when both have values for the same path. + This should be a float between 0 and 1, where 0 means always prefer the + second set and 1 means always prefer the first set. + + Raises: + ValueError: If prefer_first_probability is not between 0 and 1. + """ + if not isinstance(prefer_first_probability, float) or not ( + 0 <= prefer_first_probability <= 1 + ): + raise ValueError( + "Invalid value for `prefer_first_probability`:" + f" {prefer_first_probability!r}." + ) + + ( + made_any_crossovers, + crossed_over_samplings_to_make, + ) = _crossover_samplings_to_make_by_mixing( + predefined_samplings_1=predefined_samplings_1, + predefined_samplings_2=predefined_samplings_2, + prefer_first_probability=prefer_first_probability, + ) + + if not made_any_crossovers: + raise CrossoverNotPossibleError("No crossovers were made.") + + self._random_sampler = RandomSampler( + predefined_samplings=crossed_over_samplings_to_make, + ) + + def __call__( + self, + *, + domain_obj: Domain[T], + current_path: str, + ) -> T: + """Sample a value from the crossed-over predefined samplings or the domain. + + Args: + domain_obj: The domain object from which to sample. + current_path: The path for which to sample a value. + + Returns: + A sampled value, either from the crossed-over predefined samplings or + from the domain. + + Raises: + ValueError: If the current path is not in the crossed-over predefined + samplings and the domain does not have a prior defined. + """ + return self._random_sampler(domain_obj=domain_obj, current_path=current_path) diff --git a/neps/space/parameters.py b/neps/space/parameters.py index 868c10c0e..a08d907c1 100644 --- a/neps/space/parameters.py +++ b/neps/space/parameters.py @@ -12,7 +12,7 @@ @dataclass -class Float: +class HPOFloat: """A float value for a parameter. This kind of parameter is used to represent hyperparameters with continuous float @@ -56,19 +56,19 @@ class Float: def __post_init__(self) -> None: if self.lower >= self.upper: raise ValueError( - f"Float parameter: bounds error (lower >= upper). Actual values: " + "Float parameter: bounds error (lower >= upper). Actual values: " f"lower={self.lower}, upper={self.upper}" ) if self.log and (self.lower <= 0 or self.upper <= 0): raise ValueError( - f"Float parameter: bounds error (log scale cant have bounds <= 0). " + "Float parameter: bounds error (log scale cant have bounds <= 0). " f"Actual values: lower={self.lower}, upper={self.upper}" ) if self.prior is not None and not self.lower <= self.prior <= self.upper: raise ValueError( - f"Float parameter: prior bounds error. Expected lower <= prior <= upper, " + "Float parameter: prior bounds error. Expected lower <= prior <= upper, " f"but got lower={self.lower}, prior={self.prior}, upper={self.upper}" ) @@ -77,14 +77,14 @@ def __post_init__(self) -> None: if self.is_fidelity and (self.lower < 0 or self.upper < 0): raise ValueError( - f"Float parameter: fidelity bounds error. Expected fidelity" + "Float parameter: fidelity bounds error. Expected fidelity" f" bounds to be >= 0, but got lower={self.lower}, " f" upper={self.upper}." ) if self.is_fidelity and self.prior is not None: raise ValueError( - f"Float parameter: Fidelity parameters " + "Float parameter: Fidelity parameters " f"cannot have a prior value. Got prior={self.prior}." ) @@ -103,7 +103,7 @@ def validate(self, value: Any) -> bool: @dataclass -class Integer: +class HPOInteger: """An integer value for a parameter. This kind of parameter is used to represent hyperparameters with @@ -147,7 +147,7 @@ class Integer: def __post_init__(self) -> None: if self.lower >= self.upper: raise ValueError( - f"Integer parameter: bounds error (lower >= upper). Actual values: " + "Integer parameter: bounds error (lower >= upper). Actual values: " f"lower={self.lower}, upper={self.upper}" ) @@ -159,7 +159,7 @@ def __post_init__(self) -> None: upper_int = int(self.upper) if lower_int != self.lower or upper_int != self.upper: raise ValueError( - f"Integer parameter: bounds error (lower and upper must be integers). " + "Integer parameter: bounds error (lower and upper must be integers). " f"Actual values: lower={self.lower}, upper={self.upper}" ) @@ -168,26 +168,26 @@ def __post_init__(self) -> None: if self.is_fidelity and (self.lower < 0 or self.upper < 0): raise ValueError( - f"Integer parameter: fidelity bounds error. Expected fidelity" + "Integer parameter: fidelity bounds error. Expected fidelity" f" bounds to be >= 0, but got lower={self.lower}, " f" upper={self.upper}." ) if self.log and (self.lower <= 0 or self.upper <= 0): raise ValueError( - f"Integer parameter: bounds error (log scale cant have bounds <= 0). " + "Integer parameter: bounds error (log scale cant have bounds <= 0). " f"Actual values: lower={self.lower}, upper={self.upper}" ) if self.prior is not None and not self.lower <= self.prior <= self.upper: raise ValueError( - f"Integer parameter: Expected lower <= prior <= upper," + "Integer parameter: Expected lower <= prior <= upper," f"but got lower={self.lower}, prior={self.prior}, upper={self.upper}" ) if self.is_fidelity and self.prior is not None: raise ValueError( - f"Integer parameter: Fidelity parameters " + "Integer parameter: Fidelity parameters " f"cannot have a prior value. Got prior={self.prior}." ) @@ -200,7 +200,7 @@ def validate(self, value: Any) -> bool: @dataclass -class Categorical: +class HPOCategorical: """A list of **unordered** choices for a parameter. This kind of parameter is used to represent hyperparameters that can take on a @@ -266,7 +266,7 @@ def validate(self, value: Any) -> bool: @dataclass -class Constant: +class HPOConstant: """A constant value for a parameter. This kind of parameter is used to represent hyperparameters with values that @@ -300,12 +300,12 @@ def validate(self, value: Any) -> bool: return value == self.value -Parameter: TypeAlias = Float | Integer | Categorical +Parameter: TypeAlias = HPOFloat | HPOInteger | HPOCategorical """A type alias for all the parameter types. -* [`Float`][neps.space.Float] -* [`Integer`][neps.space.Integer] -* [`Categorical`][neps.space.Categorical] +* [`Float`][neps.space.HPOFloat] +* [`Integer`][neps.space.HPOInteger] +* [`Categorical`][neps.space.HPOCategorical] -A [`Constant`][neps.space.Constant] is not included as it does not change value. +A [`Constant`][neps.space.HPOConstant] is not included as it does not change value. """ diff --git a/neps/space/parsing.py b/neps/space/parsing.py index 6c46e7b4a..2648a6faa 100644 --- a/neps/space/parsing.py +++ b/neps/space/parsing.py @@ -9,7 +9,14 @@ from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, TypeAlias -from neps.space.parameters import Categorical, Constant, Float, Integer, Parameter +from neps.space.neps_spaces.parameters import PipelineSpace +from neps.space.parameters import ( + HPOCategorical, + HPOConstant, + HPOFloat, + HPOInteger, + Parameter, +) from neps.space.search_space import SearchSpace if TYPE_CHECKING: @@ -55,7 +62,9 @@ def scientific_parse(value: str | int | float) -> str | int | float: ) -def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: C901, PLR0911, PLR0912 +def as_parameter( # noqa: C901, PLR0911, PLR0912 + details: SerializedParameter, +) -> Parameter | HPOConstant: """Deduces the parameter type from details. Args: @@ -73,7 +82,7 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: # Constant case str() | int() | float(): val = scientific_parse(details) - return Constant(val) + return HPOConstant(val) # Bounds of float or int case tuple((x, y)): @@ -81,9 +90,9 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: _y = scientific_parse(y) match (_x, _y): case (int(), int()): - return Integer(_x, _y) + return HPOInteger(_x, _y) case (float(), float()): - return Float(_x, _y) + return HPOFloat(_x, _y) case _: raise ValueError( f"Expected both 'int' or 'float' for bounds but got {type(_x)=}" @@ -100,9 +109,9 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: _y = scientific_parse(y) match (_x, _y): case (int(), int()) if _x <= _y: # 2./3. - return Integer(_x, _y) + return HPOInteger(_x, _y) case (float(), float()) if _x <= _y: # 2./3. - return Float(_x, _y) + return HPOFloat(_x, _y) # Error case: # We do have two numbers, but of different types. This could @@ -120,7 +129,7 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: ) # At least one of them is a string, so we treat is as categorical. case _: - return Categorical(choices=[_x, _y]) + return HPOCategorical(choices=[_x, _y]) ## Categorical list of choices (tuple is reserved for bounds) case Sequence() if not isinstance(details, tuple): @@ -129,7 +138,7 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: # when specifying a grid. Hence, we map over the list and convert # what we can details = [scientific_parse(d) for d in details] - return Categorical(details) + return HPOCategorical(details) # Categorical dict declartion case {"choices": choices, **rest}: @@ -139,7 +148,7 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: # See note above about scientific notation elements choices = [scientific_parse(c) for c in choices] - return Categorical(choices, **rest) # type: ignore + return HPOCategorical(choices, **rest) # type: ignore # Constant dict declartion case {"value": v, **_rest}: @@ -150,7 +159,7 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: f" which indicates to treat value `{v}` a constant." ) - return Constant(v, **_rest) # type: ignore + return HPOConstant(v, **_rest) # type: ignore # Bounds dict declartion case {"lower": l, "upper": u, **rest}: @@ -160,18 +169,18 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: _type = rest.pop("type", None) match _type: case "int" | "integer": - return Integer(_x, _y, **rest) # type: ignore + return HPOInteger(_x, _y, **rest) # type: ignore case "float" | "floating": - return Float(_x, _y, **rest) # type: ignore + return HPOFloat(_x, _y, **rest) # type: ignore case None: match (_x, _y): case (int(), int()): - return Integer(_x, _y, **rest) # type: ignore + return HPOInteger(_x, _y, **rest) # type: ignore case (float(), float()): - return Float(_x, _y, **rest) # type: ignore + return HPOFloat(_x, _y, **rest) # type: ignore case _: raise ValueError( - f"Expected both 'int' or 'float' for bounds but" + "Expected both 'int' or 'float' for bounds but" f" got {type(_x)=} and {type(_y)=}." ) case _: @@ -188,10 +197,10 @@ def as_parameter(details: SerializedParameter) -> Parameter | Constant: # noqa: def convert_mapping(pipeline_space: Mapping[str, Any]) -> SearchSpace: """Converts a dictionary to a SearchSpace object.""" - parameters: dict[str, Parameter | Constant] = {} + parameters: dict[str, Parameter | HPOConstant] = {} for name, details in pipeline_space.items(): match details: - case Float() | Integer() | Categorical() | Constant(): + case HPOFloat() | HPOInteger() | HPOCategorical() | HPOConstant(): parameters[name] = dataclasses.replace(details) # copy case str() | int() | float() | Mapping(): try: @@ -199,7 +208,7 @@ def convert_mapping(pipeline_space: Mapping[str, Any]) -> SearchSpace: except (TypeError, ValueError) as e: raise ValueError(f"Error parsing parameter '{name}'") from e case None: - parameters[name] = Constant(None) + parameters[name] = HPOConstant(None) case _: raise ValueError( f"Unrecognized parameter type '{type(details)}' for '{name}'." @@ -208,7 +217,7 @@ def convert_mapping(pipeline_space: Mapping[str, Any]) -> SearchSpace: return SearchSpace(parameters) -def convert_configspace(configspace: ConfigurationSpace) -> SearchSpace: +def convert_configspace(configspace: ConfigurationSpace) -> SearchSpace: # noqa: C901 """Constructs a [`SearchSpace`][neps.space.SearchSpace] from a [`ConfigurationSpace`](https://automl.github.io/ConfigSpace/latest/). @@ -220,19 +229,20 @@ def convert_configspace(configspace: ConfigurationSpace) -> SearchSpace: """ import ConfigSpace as CS - space: dict[str, Parameter | Constant] = {} - if any(configspace.conditions) or any(configspace.forbidden_clauses): - raise NotImplementedError( - "The ConfigurationSpace has conditions or forbidden clauses, " - "which are not supported by neps." - ) + space: dict[str, Parameter | HPOConstant] = {} + if hasattr(configspace, "conditions") and hasattr(configspace, "forbidden_clauses"): # noqa: SIM102 + if any(configspace.conditions) or any(configspace.forbidden_clauses): + raise NotImplementedError( + "The ConfigurationSpace has conditions or forbidden clauses, " + "which are not supported by neps." + ) for name, hyperparameter in configspace.items(): match hyperparameter: case CS.Constant(): - space[name] = Constant(value=hyperparameter.value) + space[name] = HPOConstant(value=hyperparameter.value) case CS.CategoricalHyperparameter(): - space[name] = Categorical(hyperparameter.choices) # type: ignore + space[name] = HPOCategorical(hyperparameter.choices) # type: ignore case CS.OrdinalHyperparameter(): raise ValueError( "NePS does not support ordinals yet, please" @@ -240,14 +250,14 @@ def convert_configspace(configspace: ConfigurationSpace) -> SearchSpace: " categorical hyperparameter." ) case CS.UniformIntegerHyperparameter(): - space[name] = Integer( + space[name] = HPOInteger( lower=hyperparameter.lower, upper=hyperparameter.upper, log=hyperparameter.log, prior=None, ) case CS.UniformFloatHyperparameter(): - space[name] = Float( + space[name] = HPOFloat( lower=hyperparameter.lower, upper=hyperparameter.upper, log=hyperparameter.log, @@ -263,7 +273,7 @@ def convert_configspace(configspace: ConfigurationSpace) -> SearchSpace: UserWarning, stacklevel=2, ) - space[name] = Float( + space[name] = HPOFloat( lower=hyperparameter.lower, upper=hyperparameter.upper, log=hyperparameter.log, @@ -278,7 +288,7 @@ def convert_configspace(configspace: ConfigurationSpace) -> SearchSpace: UserWarning, stacklevel=2, ) - space[name] = Integer( + space[name] = HPOInteger( lower=hyperparameter.lower, upper=hyperparameter.upper, log=hyperparameter.log, @@ -295,8 +305,9 @@ def convert_to_space( Mapping[str, dict | str | int | float | Parameter] | SearchSpace | ConfigurationSpace + | PipelineSpace ), -) -> SearchSpace: +) -> SearchSpace | PipelineSpace: """Converts a search space to a SearchSpace object. Args: @@ -305,7 +316,7 @@ def convert_to_space( Returns: The SearchSpace object representing the search space. """ - # We quickly check ConfigSpace becuse it inherits from Mapping + # We quickly check ConfigSpace because it inherits from Mapping try: from ConfigSpace import ConfigurationSpace @@ -319,6 +330,8 @@ def convert_to_space( return space case Mapping(): return convert_mapping(space) + case PipelineSpace(): + return space case _: raise ValueError( f"Unsupported type '{type(space)}' for conversion to SearchSpace." diff --git a/neps/space/search_space.py b/neps/space/search_space.py index 2b0659f6a..3d727a535 100644 --- a/neps/space/search_space.py +++ b/neps/space/search_space.py @@ -9,23 +9,29 @@ from dataclasses import dataclass, field from typing import Any -from neps.space.parameters import Categorical, Constant, Float, Integer, Parameter +from neps.space.parameters import ( + HPOCategorical, + HPOConstant, + HPOFloat, + HPOInteger, + Parameter, +) # NOTE: The use of `Mapping` instead of `dict` is so that type-checkers # can check if we accidetally mutate these as we pass the parameters around. # We really should not, and instead make a copy if we really need to. @dataclass -class SearchSpace(Mapping[str, Parameter | Constant]): +class SearchSpace(Mapping[str, Parameter | HPOConstant]): """A container for parameters.""" - elements: Mapping[str, Parameter | Constant] = field(default_factory=dict) + elements: Mapping[str, Parameter | HPOConstant] = field(default_factory=dict) """All items in the search space.""" - categoricals: Mapping[str, Categorical] = field(init=False) + categoricals: Mapping[str, HPOCategorical] = field(init=False) """The categorical hyperparameters in the search space.""" - numerical: Mapping[str, Integer | Float] = field(init=False) + numerical: Mapping[str, HPOInteger | HPOFloat] = field(init=False) """The numerical hyperparameters in the search space. !!! note @@ -33,7 +39,7 @@ class SearchSpace(Mapping[str, Parameter | Constant]): This does not include fidelities. """ - fidelities: Mapping[str, Integer | Float] = field(init=False) + fidelities: Mapping[str, HPOInteger | HPOFloat] = field(init=False) """The fidelities in the search space. Currently no optimizer supports multiple fidelities but it is defined here incase. @@ -53,7 +59,7 @@ def searchables(self) -> Mapping[str, Parameter]: return {**self.numerical, **self.categoricals} @property - def fidelity(self) -> tuple[str, Float | Integer] | None: + def fidelity(self) -> tuple[str, HPOFloat | HPOInteger] | None: """The fidelity parameter for the search space.""" return None if len(self.fidelities) == 0 else next(iter(self.fidelities.items())) @@ -61,15 +67,15 @@ def __post_init__(self) -> None: # Ensure that we have a consistent order for all our items. self.elements = dict(sorted(self.elements.items(), key=lambda x: x[0])) - fidelities: dict[str, Float | Integer] = {} - numerical: dict[str, Float | Integer] = {} - categoricals: dict[str, Categorical] = {} + fidelities: dict[str, HPOFloat | HPOInteger] = {} + numerical: dict[str, HPOFloat | HPOInteger] = {} + categoricals: dict[str, HPOCategorical] = {} constants: dict[str, Any] = {} # Process the hyperparameters for name, hp in self.elements.items(): match hp: - case Float() | Integer() if hp.is_fidelity: + case HPOFloat() | HPOInteger() if hp.is_fidelity: # We should allow this at some point, but until we do, # raise an error if len(fidelities) >= 1: @@ -80,11 +86,11 @@ def __post_init__(self) -> None: ) fidelities[name] = hp - case Float() | Integer(): + case HPOFloat() | HPOInteger(): numerical[name] = hp - case Categorical(): + case HPOCategorical(): categoricals[name] = hp - case Constant(): + case HPOConstant(): constants[name] = hp.value case _: @@ -95,7 +101,7 @@ def __post_init__(self) -> None: self.constants = constants self.fidelities = fidelities - def __getitem__(self, key: str) -> Parameter | Constant: + def __getitem__(self, key: str) -> Parameter | HPOConstant: return self.elements[key] def __iter__(self) -> Iterator[str]: diff --git a/neps/state/neps_state.py b/neps/state/neps_state.py index cd8e88058..c99c9d1e9 100644 --- a/neps/state/neps_state.py +++ b/neps/state/neps_state.py @@ -739,7 +739,7 @@ def _deserialize_optimizer_info(path: Path) -> OptimizerInfo: deserialized = deserialize(path) if "name" not in deserialized or "info" not in deserialized: raise NePSError( - f"Invalid optimizer info deserialized from" + "Invalid optimizer info deserialized from" f" {path}. Did not find" " keys 'name' and 'info'." ) diff --git a/neps/status/status.py b/neps/status/status.py index e7a177e32..af427d3f3 100644 --- a/neps/status/status.py +++ b/neps/status/status.py @@ -7,13 +7,97 @@ from collections.abc import Sequence from dataclasses import asdict, dataclass, field from pathlib import Path +from typing import TYPE_CHECKING import numpy as np import pandas as pd +from neps.space.neps_spaces import neps_space +from neps.space.neps_spaces.neps_space import NepsCompatConverter +from neps.space.neps_spaces.sampling import OnlyPredefinedValuesSampler from neps.state.neps_state import FileLocker, NePSState from neps.state.trial import State, Trial +if TYPE_CHECKING: + from neps.space.neps_spaces.parameters import PipelineSpace + + +def _build_trace_texts(best_configs: list[dict]) -> tuple[str, str]: + """Build trace text and best config text from a list of best configurations. + + Args: + best_configs: List of best configuration dictionaries containing + 'trial_id', 'score', 'config', and optional metrics. + + Returns: + Tuple of (trace_text, best_config_text) strings. + """ + trace_text = ( + "Best configs and their objectives across evaluations:\n" + "-" * 80 + "\n" + ) + + for best in best_configs: + trace_text += ( + f"Config ID: {best['trial_id']}\nObjective to minimize: {best['score']}\n" + + (f"Cost: {best.get('cost', 0)}\n" if "cost" in best else "") + + ( + f"Cumulative evaluations: {best.get('cumulative_evaluations', 0)}\n" + if "cumulative_evaluations" in best + else "" + ) + + ( + f"Cumulative fidelities: {best.get('cumulative_fidelities', 0)}\n" + if "cumulative_fidelities" in best + else "" + ) + + ( + f"Cumulative cost: {best.get('cumulative_cost', 0)}\n" + if "cumulative_cost" in best + else "" + ) + + ( + f"Cumulative time: {best.get('cumulative_time', 0)}\n" + if "cumulative_time" in best + else "" + ) + + f"Config: {best['config']}\n" + + "-" * 80 + + "\n" + ) + + best_config_text = "" + if best_configs: + best_config = best_configs[-1] # Latest best + best_config_text = ( + "# Best config:" + f"\n\n Config ID: {best_config['trial_id']}" + f"\n Objective to minimize: {best_config['score']}" + + (f"\n Cost: {best_config['cost']}" if "cost" in best_config else "") + + ( + f"\n Cumulative evaluations: {best_config['cumulative_evaluations']}" + if "cumulative_evaluations" in best_config + else "" + ) + + ( + f"\n Cumulative fidelities: {best_config['cumulative_fidelities']}" + if "cumulative_fidelities" in best_config + else "" + ) + + ( + f"\n Cumulative cost: {best_config['cumulative_cost']}" + if "cumulative_cost" in best_config + else "" + ) + + ( + f"\n Cumulative time: {best_config['cumulative_time']}" + if "cumulative_time" in best_config + else "" + ) + + f"\n Config: {best_config['config']}" + ) + + return trace_text, best_config_text + @dataclass class Summary: @@ -67,12 +151,12 @@ def df(self) -> pd.DataFrame: metadata_df = pd.DataFrame.from_records( [asdict(t.metadata) for t in trials] ).convert_dtypes() - - return ( - pd.concat([config_df, extra_df, report_df, metadata_df], axis="columns") - .set_index("id") - .dropna(how="all", axis="columns") + combined_df = pd.concat( + [config_df, extra_df, report_df, metadata_df], axis="columns" ) + if combined_df.empty: + return combined_df + return combined_df.set_index("id").dropna(how="all", axis="columns") def completed(self) -> list[Trial]: """Return all trials which are in a completed state.""" @@ -98,8 +182,31 @@ def num_pending(self) -> int: """Number of trials that are pending.""" return len(self.by_state[State.PENDING]) - def formatted(self) -> str: - """Return a formatted string of the summary.""" + def formatted( # noqa: PLR0912, C901 + self, pipeline_space_variables: tuple[PipelineSpace, list[str]] | None = None + ) -> str: + """Return a formatted string of the summary. + + Args: + pipeline_space_variables: If provided, this tuple contains the Pipeline and a + list of variable names to format the config in the summary. This is useful + for pipelines that have a complex configuration structure, allowing for a + more readable output. + + !!! Warning: + + This is only supported when using NePS-only optimizers, such as + `neps.algorithms.neps_random_search`, + `neps.algorithms.complex_random_search` + or `neps.algorithms.neps_priorband`. When the search space is + simple enough, using `neps.algorithms.random_search` or + `neps.algorithms.priorband` is not enough, as it will be transformed + to a simpler HPO framework, which is incompatible with the + `pipeline_space_variables` argument. + + Returns: + A formatted string of the summary. + """ state_summary = "\n".join( f" {state.name.lower()}: {len(trials)}" for state, trials in self.by_state.items() @@ -113,13 +220,69 @@ def formatted(self) -> str: best_summary = "No best found yet." else: best_trial, best_objective_to_minimize = self.best + + # Format config based on whether pipeline_space_variables is provided + best_summary = ( f"# Best Found (config {best_trial.metadata.id}):" "\n" f"\n objective_to_minimize: {best_objective_to_minimize}" - f"\n config: {best_trial.config}" - f"\n path: {best_trial.metadata.location}" ) + if pipeline_space_variables is None: + best_summary += f"\n config: {best_trial.config}" + else: + best_config_resolve = NepsCompatConverter().from_neps_config( + best_trial.config + ) + pipeline_configs = [] + for variable in pipeline_space_variables[1]: + pipeline_configs.append( + neps_space.config_string.ConfigString( + neps_space.convert_operation_to_string( + getattr( + neps_space.resolve( + pipeline_space_variables[0], + OnlyPredefinedValuesSampler( + best_config_resolve.predefined_samplings + ), + environment_values=best_config_resolve.environment_values, + )[0], + variable, + ) + ) + ).pretty_format() + ) + + for n_pipeline, pipeline_config in enumerate(pipeline_configs): + if isinstance(pipeline_config, str): + # Replace literal \t and \n with actual formatting + formatted_config = pipeline_config.replace("\\t", " ").replace( + "\\n", "\n" + ) + + # Add proper indentation to each line + lines = formatted_config.split("\n") + indented_lines = [] + for i, line in enumerate(lines): + if i == 0: + indented_lines.append( + line + ) # First line gets base indentation + else: + indented_lines.append( + " " + line + ) # Subsequent lines get extra indentation + + formatted_config = "\n".join(indented_lines) + else: + formatted_config = pipeline_config # type: ignore + best_summary += ( + f"\n config: {pipeline_space_variables[1][n_pipeline]}\n " + f" {formatted_config}" + ) + + best_summary += f"\n path: {best_trial.metadata.location}" + assert best_trial.report is not None if best_trial.report.cost is not None: best_summary += f"\n cost: {best_trial.report.cost}" @@ -173,12 +336,28 @@ def status( root_directory: str | Path, *, print_summary: bool = False, + pipeline_space_variables: tuple[PipelineSpace, list[str]] | None = None, ) -> tuple[pd.DataFrame, pd.Series]: """Print status information of a neps run and return results. Args: root_directory: The root directory given to neps.run. - print_summary: If true, print a summary of the current run state + print_summary: If true, print a summary of the current run state. + pipeline_space_variables: If provided, this tuple contains the Pipeline and a + list of variable names to format the config in the summary. This is useful + for pipelines that have a complex configuration structure, allowing for a + more readable output. + + !!! Warning: + + This is only supported when using NePS-only optimizers, such as + `neps.algorithms.neps_random_search`, + `neps.algorithms.complex_random_search` + or `neps.algorithms.neps_priorband`. When the search space is + simple enough, using `neps.algorithms.random_search` or + `neps.algorithms.priorband` is not enough, as it will be transformed to a + simpler HPO framework, which is incompatible with the + `pipeline_space_variables` argument. Returns: Dataframe of full results and short summary series. @@ -187,7 +366,7 @@ def status( summary = Summary.from_directory(root_directory) if print_summary: - print(summary.formatted()) + print(summary.formatted(pipeline_space_variables=pipeline_space_variables)) df = summary.df() @@ -226,79 +405,6 @@ def status( return df, short -def trajectory_of_improvements( - root_directory: str | Path, -) -> list[dict]: - """Track and write the trajectory of improving configurations over time. - - Args: - root_directory: The root directory given to neps.run. - - Returns: - List of dicts with improving scores and their configurations. - """ - root_directory = Path(root_directory) - summary = Summary.from_directory(root_directory) - - if summary.is_multiobjective: - return [] - - df = summary.df() - - if "time_sampled" not in df.columns: - raise ValueError("Missing `time_sampled` column in summary DataFrame.") - - df = df.sort_values("time_sampled") - - all_best_configs = [] - best_score = float("inf") - trace_text = "" - - for trial_id, row in df.iterrows(): - if "objective_to_minimize" not in row or pd.isna(row["objective_to_minimize"]): - continue - - score = row["objective_to_minimize"] - if score < best_score: - best_score = score - config = { - k.replace("config.", ""): v - for k, v in row.items() - if k.startswith("config.") - } - - best = { - "score": score, - "trial_id": trial_id, - "config": config, - } - all_best_configs.append(best) - - trace_text += ( - f"Objective to minimize: {best['score']}\n" - f"Config ID: {best['trial_id']}\n" - f"Config: {best['config']}\n" + "-" * 80 + "\n" - ) - - summary_dir = root_directory / "summary" - summary_dir.mkdir(parents=True, exist_ok=True) - output_path = summary_dir / "best_config_trajectory.txt" - with output_path.open("w") as f: - f.write(trace_text) - - if all_best_configs: - final_best = all_best_configs[-1] - best_path = summary_dir / "best_config.txt" - with best_path.open("w") as f: - f.write( - f"Objective to minimize: {final_best['score']}\n" - f"Config ID: {final_best['trial_id']}\n" - f"Config: {final_best['config']}\n" - ) - - return all_best_configs - - def _initiate_summary_csv(root_directory: str | Path) -> tuple[Path, Path, FileLocker]: """Initializes a summary CSV and an associated locker for file access control. diff --git a/neps_examples/__init__.py b/neps_examples/__init__.py index f1c8f4631..bd41652af 100644 --- a/neps_examples/__init__.py +++ b/neps_examples/__init__.py @@ -25,6 +25,7 @@ core_examples = [ # Run locally and on github actions "basic_usage/hyperparameters", # NOTE: This needs to be first for some tests to work "basic_usage/analyse", + "basic_usage/pytorch_nn_example", "experimental/expert_priors_for_architecture_and_hyperparameters", "efficiency/multi_fidelity", ] diff --git a/neps_examples/basic_usage/hyperparameters.py b/neps_examples/basic_usage/hyperparameters.py index 0f3fdc898..8163e2f63 100644 --- a/neps_examples/basic_usage/hyperparameters.py +++ b/neps_examples/basic_usage/hyperparameters.py @@ -13,20 +13,20 @@ def evaluate_pipeline(float1, float2, categorical, integer1, integer2): np.sum([float1, float2, int(categorical), integer1, integer2]) ) return objective_to_minimize - -pipeline_space = dict( - float1=neps.Float(lower=0, upper=1), - float2=neps.Float(lower=-10, upper=10), - categorical=neps.Categorical(choices=[0, 1]), - integer1=neps.Integer(lower=0, upper=1), - integer2=neps.Integer(lower=1, upper=1000, log=True), -) + +class HPOSpace(neps.PipelineSpace): + float1 = neps.Float(min_value=0, max_value=1) + float2 = neps.Float(min_value=-10, max_value=10) + categorical = neps.Categorical(choices=(0, 1)) + integer1 = neps.Integer(min_value=0, max_value=1) + integer2 = neps.Integer(min_value=1, max_value=1000, log=True) + logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/hyperparameters_example", evaluations_to_spend=30, worker_id=f"worker_1-{socket.gethostname()}-{os.getpid()}", diff --git a/neps_examples/basic_usage/pytorch_nn_example.py b/neps_examples/basic_usage/pytorch_nn_example.py new file mode 100644 index 000000000..41242df56 --- /dev/null +++ b/neps_examples/basic_usage/pytorch_nn_example.py @@ -0,0 +1,159 @@ +""" +This example demonstrates the full capabilities of NePS Spaces +by defining a neural network architecture using PyTorch modules. +It showcases how to interact with the NePS Spaces API to create, +sample and evaluate a neural network pipeline. +It also demonstrates how to convert the pipeline to a callable +and how to run NePS with the defined pipeline and space. +""" + +import numpy as np +import torch +import torch.nn as nn +import neps +from neps.space.neps_spaces.parameters import ( + PipelineSpace, + Operation, + Categorical, + Resampled, +) +from neps.space.neps_spaces import neps_space + + +# Define the neural network architecture using PyTorch as usual +class ReLUConvBN(nn.Module): + def __init__(self, out_channels, kernel_size, stride, padding): + super().__init__() + + self.kernel_size = kernel_size + self.op = nn.Sequential( + nn.ReLU(inplace=False), + nn.LazyConv2d( + out_channels=out_channels, + kernel_size=kernel_size, + stride=stride, + padding=padding, + dilation=2, + bias=False, + ), + nn.LazyBatchNorm2d(affine=True, track_running_stats=True), + ) + + def forward(self, x): + return self.op(x) + + +class Identity(nn.Module): + def __init__(self): + super().__init__() + + def forward(self, x): + return x + + +# Define the NEPS space for the neural network architecture +class NN_Space(PipelineSpace): + _id = Operation(operator=Identity) + _three = Operation( + operator=nn.Conv2d, + kwargs={ + "in_channels": 3, + "out_channels": 3, + "kernel_size": 3, + "stride": 1, + "padding": 1, + }, + ) + _one = Operation( + operator=nn.Conv2d, + kwargs={ + "in_channels": 3, + "out_channels": 3, + "kernel_size": 1, + "stride": 1, + "padding": 0, + }, + ) + _reluconvbn = Operation( + operator=ReLUConvBN, + kwargs={"out_channels": 3, "kernel_size": 3, "stride": 1, "padding": 1}, + ) + + _O = Categorical(choices=(_three, _one, _id)) + + _C_ARGS = Categorical( + choices=( + (Resampled(_O),), + (Resampled(_O), Resampled("model"), _reluconvbn), + (Resampled(_O), Resampled("model")), + (Resampled("model"),), + ), + ) + _C = Operation( + operator=nn.Sequential, + args=Resampled(_C_ARGS), + ) + + _model_ARGS = Categorical( + choices=( + (Resampled(_C),), + (_reluconvbn,), + (Resampled("model"),), + (Resampled("model"), Resampled(_C)), + (Resampled(_O), Resampled(_O), Resampled(_O)), + ( + Resampled("model"), + Resampled("model"), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + ), + ), + ) + model = Operation( + operator=nn.Sequential, + args=Resampled(_model_ARGS), + ) + + +# Sampling and printing one random configuration of the pipeline +pipeline = NN_Space() +resolved_pipeline, resolution_context = neps_space.resolve(pipeline) + +s = resolved_pipeline.model +s_config_string = neps_space.convert_operation_to_string(s) +pretty_config = neps_space.config_string.ConfigString(s_config_string).pretty_format() +s_callable = neps_space.convert_operation_to_callable(s) + +print("Callable:\n") +print(s_callable) + +print("\n\nConfig string:\n") +print(pretty_config) + + +# Defining the pipeline, using the model from the NN_space space as callable +def evaluate_pipeline(model: nn.Sequential): + x = torch.ones(size=[1, 3, 220, 220]) + result = np.sum(model(x).detach().numpy().flatten()) + return result + + +# Run NePS with the defined pipeline and space and show the best configuration +pipeline_space = NN_Space() +neps.run( + evaluate_pipeline=evaluate_pipeline, + pipeline_space=pipeline_space, + optimizer=neps.algorithms.neps_random_search, + root_directory="results/neps_spaces_nn_example", + evaluations_to_spend=5, + overwrite_root_directory=True, +) +neps.status( + "results/neps_spaces_nn_example", + print_summary=True, + pipeline_space_variables=(pipeline_space, ["model"]), +) diff --git a/neps_examples/convenience/async_evaluation/submit.py b/neps_examples/convenience/async_evaluation/submit.py index f7021dee9..7bb9c6d4b 100644 --- a/neps_examples/convenience/async_evaluation/submit.py +++ b/neps_examples/convenience/async_evaluation/submit.py @@ -34,14 +34,13 @@ def evaluate_pipeline_via_slurm(pipeline_id, pipeline_directory, previous_pipeli return None -pipeline_space = dict( - optimizer=neps.Categorical(choices=["sgd", "adam"]), - lr=neps.Float(lower=10e-7, upper=10e-3, log=True), -) +class ExampleSpace(neps.PipelineSpace): + optimizer=neps.Categorical(choices=["sgd", "adam"]) + lr=neps.Float(lower=10e-7, upper=10e-3, log=True) neps.run( evaluate_pipeline=evaluate_pipeline_via_slurm, - pipeline_space=pipeline_space, + pipeline_space=ExampleSpace(), root_directory="results", max_evaluations_per_run=2, ) diff --git a/neps_examples/convenience/logging_additional_info.py b/neps_examples/convenience/logging_additional_info.py index 3120c7db5..a511c9318 100644 --- a/neps_examples/convenience/logging_additional_info.py +++ b/neps_examples/convenience/logging_additional_info.py @@ -21,18 +21,18 @@ def evaluate_pipeline(float1, float2, categorical, integer1, integer2): } -pipeline_space = dict( - float1=neps.Float(lower=0, upper=1), - float2=neps.Float(lower=-10, upper=10), - categorical=neps.Categorical(choices=[0, 1]), - integer1=neps.Integer(lower=0, upper=1), - integer2=neps.Integer(lower=1, upper=1000, log=True), -) +class HPOSpace(neps.PipelineSpace): + float1 = neps.Float(min_value=0, max_value=1) + float2 = neps.Float(min_value=-10, max_value=10) + categorical = neps.Categorical(choices=(0, 1)) + integer1 = neps.Integer(min_value=0, max_value=1) + integer2 = neps.Integer(min_value=1, max_value=1000, log=True) + logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/logging_additional_info", evaluations_to_spend=5, ) diff --git a/neps_examples/convenience/neps_tblogger_tutorial.py b/neps_examples/convenience/neps_tblogger_tutorial.py index 938ca0e2c..2d1e44ff3 100644 --- a/neps_examples/convenience/neps_tblogger_tutorial.py +++ b/neps_examples/convenience/neps_tblogger_tutorial.py @@ -202,9 +202,7 @@ def training( optimizer.step() # Calculate validation objective_to_minimize using the objective_to_minimize_ev function. - validation_objective_to_minimize = objective_to_minimize_ev( - model, validation_loader - ) + validation_objective_to_minimize = objective_to_minimize_ev(model, validation_loader) return validation_objective_to_minimize @@ -212,14 +210,13 @@ def training( # Design the pipeline search spaces. -def pipeline_space() -> dict: - pipeline = dict( - lr=neps.Float(lower=1e-5, upper=1e-1, log=True), - optim=neps.Categorical(choices=["Adam", "SGD"]), - weight_decay=neps.Float(lower=1e-4, upper=1e-1, log=True), - ) +def pipeline_space() -> neps.PipelineSpace: + class HPOSpace(neps.PipelineSpace): + lr = neps.Float(min_value=1e-5, max_value=1e-1, log=True) + optim = neps.Categorical(choices=("Adam", "SGD")) + weight_decay = neps.Float(min_value=1e-4, max_value=1e-1, log=True) - return pipeline + return HPOSpace() ############################################################# @@ -229,13 +226,9 @@ def evaluate_pipeline(lr, optim, weight_decay): model = MLP() if optim == "Adam": - optimizer = torch.optim.Adam( - model.parameters(), lr=lr, weight_decay=weight_decay - ) + optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay) elif optim == "SGD": - optimizer = torch.optim.SGD( - model.parameters(), lr=lr, weight_decay=weight_decay - ) + optimizer = torch.optim.SGD(model.parameters(), lr=lr, weight_decay=weight_decay) else: raise ValueError( "Optimizer choices are defined differently in the pipeline_space" diff --git a/neps_examples/convenience/running_on_slurm_scripts.py b/neps_examples/convenience/running_on_slurm_scripts.py index 26dac7082..a8c8658dc 100644 --- a/neps_examples/convenience/running_on_slurm_scripts.py +++ b/neps_examples/convenience/running_on_slurm_scripts.py @@ -50,15 +50,15 @@ def evaluate_pipeline_via_slurm( return validation_error -pipeline_space = dict( - optimizer=neps.Categorical(choices=["sgd", "adam"]), - learning_rate=neps.Float(lower=10e-7, upper=10e-3, log=True), -) +class HPOSpace(neps.PipelineSpace): + optimizer = neps.Categorical(choices=("sgd", "adam")) + learning_rate = neps.Float(min_value=10e-7, max_value=10e-3, log=True) + logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline_via_slurm, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/slurm_script_example", evaluations_to_spend=5, ) diff --git a/neps_examples/convenience/working_directory_per_pipeline.py b/neps_examples/convenience/working_directory_per_pipeline.py index 7b1b5ad13..cbf510e4a 100644 --- a/neps_examples/convenience/working_directory_per_pipeline.py +++ b/neps_examples/convenience/working_directory_per_pipeline.py @@ -18,16 +18,16 @@ def evaluate_pipeline(pipeline_directory: Path, float1, categorical, integer1): return objective_to_minimize -pipeline_space = dict( - float1=neps.Float(lower=0, upper=1), - categorical=neps.Categorical(choices=[0, 1]), - integer1=neps.Integer(lower=0, upper=1), -) +class HPOSpace(neps.PipelineSpace): + float1 = neps.Float(min_value=0, max_value=1) + categorical = neps.Categorical(choices=(0, 1)) + integer1 = neps.Integer(min_value=0, max_value=1) + logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/working_directory_per_pipeline", evaluations_to_spend=5, ) diff --git a/neps_examples/efficiency/expert_priors_for_hyperparameters.py b/neps_examples/efficiency/expert_priors_for_hyperparameters.py index 32633930b..b84a66c05 100644 --- a/neps_examples/efficiency/expert_priors_for_hyperparameters.py +++ b/neps_examples/efficiency/expert_priors_for_hyperparameters.py @@ -22,31 +22,37 @@ def evaluate_pipeline(some_float, some_integer, some_cat): # neps uses the default values and a confidence in this default value to construct a prior # that speeds up the search -pipeline_space = dict( - some_float=neps.Float( - lower=1, - upper=1000, - log=True, - prior=900, - prior_confidence="medium", - ), - some_integer=neps.Integer( - lower=0, - upper=50, - prior=35, - prior_confidence="low", - ), - some_cat=neps.Categorical( - choices=["a", "b", "c"], - prior="a", - prior_confidence="high", - ), -) +class HPOSpace(neps.PipelineSpace): + some_float = ( + neps.Float( + min_value=1, + max_value=1000, + log=True, + prior=900, + prior_confidence="medium", + ), + ) + some_integer = ( + neps.Integer( + min_value=0, + max_value=50, + prior=35, + prior_confidence="low", + ), + ) + some_cat = ( + neps.Categorical( + choices=("a", "b", "c"), + prior=0, + prior_confidence="high", + ), + ) + logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/user_priors_example", evaluations_to_spend=15, ) diff --git a/neps_examples/efficiency/multi_fidelity.py b/neps_examples/efficiency/multi_fidelity.py index b067eac2b..1e5ff32a3 100644 --- a/neps_examples/efficiency/multi_fidelity.py +++ b/neps_examples/efficiency/multi_fidelity.py @@ -46,8 +46,9 @@ def get_model_and_optimizer(learning_rate): def evaluate_pipeline( pipeline_directory: Path, # The path associated with this configuration - previous_pipeline_directory: Path - | None, # The path associated with any previous config + previous_pipeline_directory: ( + Path | None + ), # The path associated with any previous config learning_rate: float, epoch: int, ) -> dict: @@ -82,15 +83,15 @@ def evaluate_pipeline( ) -pipeline_space = dict( - learning_rate=neps.Float(lower=1e-4, upper=1e0, log=True), - epoch=neps.Integer(lower=1, upper=10, is_fidelity=True), -) +class HPOSpace(neps.PipelineSpace): + learning_rate = neps.Float(min_value=1e-4, max_value=1e0, log=True) + epoch = neps.Fidelity(neps.Integer(min_value=1, max_value=10)) + logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/multi_fidelity_example", # Optional: Do not start another evaluation after <=50 epochs, corresponds to cost # field above. diff --git a/neps_examples/efficiency/multi_fidelity_and_expert_priors.py b/neps_examples/efficiency/multi_fidelity_and_expert_priors.py index 5e8ff2aab..af9ce63c8 100644 --- a/neps_examples/efficiency/multi_fidelity_and_expert_priors.py +++ b/neps_examples/efficiency/multi_fidelity_and_expert_priors.py @@ -6,42 +6,39 @@ # This example demonstrates NePS uses both fidelity and expert priors to # optimize hyperparameters of a pipeline. + def evaluate_pipeline(float1, float2, integer1, fidelity): objective_to_minimize = -float(np.sum([float1, float2, integer1])) / fidelity return objective_to_minimize -pipeline_space = dict( - float1=neps.Float( - lower=1, - upper=1000, +class HPOSpace(neps.PipelineSpace): + float1 = neps.Float( + min_value=1, + max_value=1000, log=False, prior=600, prior_confidence="medium", - ), - float2=neps.Float( - lower=-10, - upper=10, + ) + float2 = neps.Float( + min_value=-10, + max_value=10, prior=0, prior_confidence="medium", - ), - integer1=neps.Integer( - lower=0, - upper=50, + ) + integer1 = neps.Integer( + min_value=0, + max_value=50, prior=35, prior_confidence="low", - ), - fidelity=neps.Integer( - lower=1, - upper=10, - is_fidelity=True, - ), -) + ) + fidelity = neps.Fidelity(neps.Integer(min_value=1, max_value=10)) + logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/multifidelity_priors", fidelities_to_spend=25, # For an alternate stopping method see multi_fidelity.py ) diff --git a/neps_examples/efficiency/pytorch_lightning_ddp.py b/neps_examples/efficiency/pytorch_lightning_ddp.py index d40a29cdd..cd3bb0896 100644 --- a/neps_examples/efficiency/pytorch_lightning_ddp.py +++ b/neps_examples/efficiency/pytorch_lightning_ddp.py @@ -11,7 +11,8 @@ class ToyModel(nn.Module): - """ Taken from https://pytorch.org/tutorials/intermediate/ddp_tutorial.html """ + """Taken from https://pytorch.org/tutorials/intermediate/ddp_tutorial.html""" + def __init__(self): super(ToyModel, self).__init__() self.net1 = nn.Linear(10, 10) @@ -21,6 +22,7 @@ def __init__(self): def forward(self, x): return self.net2(self.relu(self.net1(x))) + class LightningModel(L.LightningModule): def __init__(self, lr): super().__init__() @@ -51,6 +53,7 @@ def test_step(self, batch, batch_idx): def configure_optimizers(self): return torch.optim.SGD(self.parameters(), lr=self.lr) + def evaluate_pipeline(lr=0.1, epoch=20): L.seed_everything(42) # Model @@ -70,35 +73,27 @@ def evaluate_pipeline(lr=0.1, epoch=20): test_dataloader = DataLoader(test_dataset, batch_size=20, shuffle=False) # Trainer with DDP Strategy - trainer = L.Trainer(gradient_clip_val=0.25, - max_epochs=epoch, - fast_dev_run=False, - strategy='ddp', - devices=NUM_GPU - ) + trainer = L.Trainer( + gradient_clip_val=0.25, + max_epochs=epoch, + fast_dev_run=False, + strategy="ddp", + devices=NUM_GPU, + ) trainer.fit(model, train_dataloader, val_dataloader) trainer.validate(model, test_dataloader) return trainer.logged_metrics["val_loss"].item() -pipeline_space = dict( - lr=neps.Float( - lower=0.001, - upper=0.1, - log=True, - prior=0.01 - ), - epoch=neps.Integer( - lower=1, - upper=3, - is_fidelity=True - ) - ) +class HPOSpace(neps.PipelineSpace): + lr = neps.Float(min_value=0.001, max_value=0.1, log=True, prior=0.01) + epoch = neps.Fidelity(neps.Integer(min_value=1, max_value=3)) + logging.basicConfig(level=logging.INFO) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/pytorch_lightning_ddp", fidelities_to_spend=5 ) diff --git a/neps_examples/efficiency/pytorch_lightning_fsdp.py b/neps_examples/efficiency/pytorch_lightning_fsdp.py index c7317bfc6..73835a733 100644 --- a/neps_examples/efficiency/pytorch_lightning_fsdp.py +++ b/neps_examples/efficiency/pytorch_lightning_fsdp.py @@ -56,23 +56,13 @@ def evaluate_pipeline(lr=0.1, epoch=20): logging.basicConfig(level=logging.INFO) - pipeline_space = dict( - lr=neps.Float( - lower=0.0001, - upper=0.1, - log=True, - prior=0.01 - ), - epoch=neps.Integer( - lower=1, - upper=3, - is_fidelity=True - ) - ) + class HPOSpace(neps.PipelineSpace): + lr = neps.Float(min_value=0.001, max_value=0.1, log=True, prior=0.01) + epoch = neps.Fidelity(neps.Integer(min_value=1, max_value=3)) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/pytorch_lightning_fsdp", fidelities_to_spend=5 ) diff --git a/neps_examples/efficiency/pytorch_native_ddp.py b/neps_examples/efficiency/pytorch_native_ddp.py index 9477d6e95..79debab5a 100644 --- a/neps_examples/efficiency/pytorch_native_ddp.py +++ b/neps_examples/efficiency/pytorch_native_ddp.py @@ -1,4 +1,4 @@ -""" Some parts of this code are taken from https://pytorch.org/tutorials/intermediate/ddp_tutorial.html +"""Some parts of this code are taken from https://pytorch.org/tutorials/intermediate/ddp_tutorial.html Mind that this example does not run on Windows at the moment.""" @@ -32,8 +32,8 @@ def setup(rank, world_size): - os.environ['MASTER_ADDR'] = 'localhost' - os.environ['MASTER_PORT'] = '12355' + os.environ["MASTER_ADDR"] = "localhost" + os.environ["MASTER_PORT"] = "12355" # initialize the process group dist.init_process_group("gloo", rank=rank, world_size=world_size) @@ -44,7 +44,8 @@ def cleanup(): class ToyModel(nn.Module): - """ Taken from https://pytorch.org/tutorials/intermediate/ddp_tutorial.html """ + """Taken from https://pytorch.org/tutorials/intermediate/ddp_tutorial.html""" + def __init__(self): super(ToyModel, self).__init__() self.net1 = nn.Linear(10, 10) @@ -56,7 +57,7 @@ def forward(self, x): def demo_basic(rank, world_size, loss_dict, learning_rate, epochs): - """ Taken from https://pytorch.org/tutorials/intermediate/ddp_tutorial.html (modified)""" + """Taken from https://pytorch.org/tutorials/intermediate/ddp_tutorial.html (modified)""" print(f"Running basic DDP example on rank {rank}.") setup(rank, world_size) @@ -88,28 +89,31 @@ def demo_basic(rank, world_size, loss_dict, learning_rate, epochs): def evaluate_pipeline(learning_rate, epochs): from torch.multiprocessing import Manager + world_size = NUM_GPU # Number of GPUs manager = Manager() loss_dict = manager.dict() - mp.spawn(demo_basic, - args=(world_size, loss_dict, learning_rate, epochs), - nprocs=world_size, - join=True) + mp.spawn( + demo_basic, + args=(world_size, loss_dict, learning_rate, epochs), + nprocs=world_size, + join=True, + ) loss = sum(loss_dict.values()) // world_size - return {'loss': loss} + return {"loss": loss} + +class HPOSpace(neps.PipelineSpace): + learning_rate = neps.Float(min_value=10e-7, max_value=10e-3, log=True) + epochs = neps.Integer(min_value=1, max_value=3) -pipeline_space = dict( - learning_rate=neps.Float(lower=10e-7, upper=10e-3, log=True), - epochs=neps.Integer(lower=1, upper=3) -) -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.INFO) neps.run(evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/pytorch_ddp", evaluations_to_spend=25) diff --git a/neps_examples/efficiency/pytorch_native_fsdp.py b/neps_examples/efficiency/pytorch_native_fsdp.py index 4b40e318a..cfe6d3831 100644 --- a/neps_examples/efficiency/pytorch_native_fsdp.py +++ b/neps_examples/efficiency/pytorch_native_fsdp.py @@ -24,18 +24,21 @@ size_based_auto_wrap_policy, ) -NUM_GPU = 8 # Number of GPUs to use for FSDP +NUM_GPU = 8 # Number of GPUs to use for FSDP + def setup(rank, world_size): - os.environ['MASTER_ADDR'] = 'localhost' - os.environ['MASTER_PORT'] = '12355' + os.environ["MASTER_ADDR"] = "localhost" + os.environ["MASTER_PORT"] = "12355" # initialize the process group dist.init_process_group("nccl", rank=rank, world_size=world_size) + def cleanup(): dist.destroy_process_group() + class Net(nn.Module): def __init__(self): super(Net, self).__init__() @@ -62,6 +65,7 @@ def forward(self, x): output = F.log_softmax(x, dim=1) return output + def train(model, rank, world_size, train_loader, optimizer, epoch, sampler=None): model.train() ddp_loss = torch.zeros(2).to(rank) @@ -71,7 +75,7 @@ def train(model, rank, world_size, train_loader, optimizer, epoch, sampler=None) data, target = data.to(rank), target.to(rank) optimizer.zero_grad() output = model(data) - loss = F.nll_loss(output, target, reduction='sum') + loss = F.nll_loss(output, target, reduction="sum") loss.backward() optimizer.step() ddp_loss[0] += loss.item() @@ -79,7 +83,8 @@ def train(model, rank, world_size, train_loader, optimizer, epoch, sampler=None) dist.all_reduce(ddp_loss, op=dist.ReduceOp.SUM) if rank == 0: - print('Train Epoch: {} \tLoss: {:.6f}'.format(epoch, ddp_loss[0] / ddp_loss[1])) + print("Train Epoch: {} \tLoss: {:.6f}".format(epoch, ddp_loss[0] / ddp_loss[1])) + def test(model, rank, world_size, test_loader): model.eval() @@ -89,8 +94,12 @@ def test(model, rank, world_size, test_loader): for data, target in test_loader: data, target = data.to(rank), target.to(rank) output = model(data) - ddp_loss[0] += F.nll_loss(output, target, reduction='sum').item() # sum up batch loss - pred = output.argmax(dim=1, keepdim=True) # get the index of the max log-probability + ddp_loss[0] += F.nll_loss( + output, target, reduction="sum" + ).item() # sum up batch loss + pred = output.argmax( + dim=1, keepdim=True + ) # get the index of the max log-probability ddp_loss[1] += pred.eq(target.view_as(pred)).sum().item() ddp_loss[2] += len(data) @@ -99,43 +108,45 @@ def test(model, rank, world_size, test_loader): test_loss = math.inf if rank == 0: test_loss = ddp_loss[0] / ddp_loss[2] - print('Test set: Average loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n'.format( - test_loss, int(ddp_loss[1]), int(ddp_loss[2]), - 100. * ddp_loss[1] / ddp_loss[2])) + print( + "Test set: Average loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n".format( + test_loss, + int(ddp_loss[1]), + int(ddp_loss[2]), + 100.0 * ddp_loss[1] / ddp_loss[2], + ) + ) return test_loss + def fsdp_main(rank, world_size, test_loss_tensor, lr, epochs, save_model=False): setup(rank, world_size) - transform=transforms.Compose([ - transforms.ToTensor(), - transforms.Normalize((0.1307,), (0.3081,)) - ]) + transform = transforms.Compose( + [transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))] + ) - dataset1 = datasets.MNIST('./', train=True, download=True, - transform=transform) - dataset2 = datasets.MNIST('./', train=False, - transform=transform) + dataset1 = datasets.MNIST("./", train=True, download=True, transform=transform) + dataset2 = datasets.MNIST("./", train=False, transform=transform) - sampler1 = DistributedSampler(dataset1, rank=rank, num_replicas=world_size, shuffle=True) + sampler1 = DistributedSampler( + dataset1, rank=rank, num_replicas=world_size, shuffle=True + ) sampler2 = DistributedSampler(dataset2, rank=rank, num_replicas=world_size) - train_kwargs = {'batch_size': 64, 'sampler': sampler1} - test_kwargs = {'batch_size': 1000, 'sampler': sampler2} - cuda_kwargs = {'num_workers': 2, - 'pin_memory': True, - 'shuffle': False} + train_kwargs = {"batch_size": 64, "sampler": sampler1} + test_kwargs = {"batch_size": 1000, "sampler": sampler2} + cuda_kwargs = {"num_workers": 2, "pin_memory": True, "shuffle": False} train_kwargs.update(cuda_kwargs) test_kwargs.update(cuda_kwargs) - train_loader = torch.utils.data.DataLoader(dataset1,**train_kwargs) + train_loader = torch.utils.data.DataLoader(dataset1, **train_kwargs) test_loader = torch.utils.data.DataLoader(dataset2, **test_kwargs) my_auto_wrap_policy = functools.partial( size_based_auto_wrap_policy, min_num_params=100 ) torch.cuda.set_device(rank) - init_start_event = torch.cuda.Event(enable_timing=True) init_end_event = torch.cuda.Event(enable_timing=True) @@ -163,7 +174,10 @@ def fsdp_main(rank, world_size, test_loss_tensor, lr, epochs, save_model=False): if rank == 0: init_end_event.synchronize() - print(f"CUDA event elapsed time: {init_start_event.elapsed_time(init_end_event) / 1000}sec") + print( + "CUDA event elapsed time:" + f" {init_start_event.elapsed_time(init_end_event) / 1000}sec" + ) if save_model: # use a barrier to make sure training is done on all ranks @@ -173,16 +187,16 @@ def fsdp_main(rank, world_size, test_loss_tensor, lr, epochs, save_model=False): torch.save(states, "mnist_cnn.pt") cleanup() + def evaluate_pipeline(lr=0.1, epoch=20): torch.manual_seed(42) test_loss_tensor = torch.zeros(1) test_loss_tensor.share_memory_() - mp.spawn(fsdp_main, - args=(NUM_GPU, test_loss_tensor, lr, epoch), - nprocs=NUM_GPU, - join=True) + mp.spawn( + fsdp_main, args=(NUM_GPU, test_loss_tensor, lr, epoch), nprocs=NUM_GPU, join=True + ) loss = test_loss_tensor.item() return loss @@ -194,23 +208,13 @@ def evaluate_pipeline(lr=0.1, epoch=20): logging.basicConfig(level=logging.INFO) - pipeline_space = dict( - lr=neps.Float( - lower=0.0001, - upper=0.1, - log=True, - prior=0.01 - ), - epoch=neps.Integer( - lower=1, - upper=3, - is_fidelity=True - ) - ) + class HPOSpace(neps.PipelineSpace): + lr = neps.Float(min_value=0.0001, max_value=0.1, log=True, prior=0.01) + epoch = neps.Fidelity(neps.Integer(min_value=1, max_value=3)) neps.run( evaluate_pipeline=evaluate_pipeline, - pipeline_space=pipeline_space, + pipeline_space=HPOSpace(), root_directory="results/pytorch_fsdp", - fidelities_to_spend=20 - ) + fidelities_to_spend=20, + ) diff --git a/neps_examples/experimental/freeze_thaw.py b/neps_examples/experimental/freeze_thaw.py index 656927f26..e8391ac1e 100644 --- a/neps_examples/experimental/freeze_thaw.py +++ b/neps_examples/experimental/freeze_thaw.py @@ -53,14 +53,10 @@ def training_pipeline( KeyError: If the specified optimizer is not supported. """ # Transformations applied on each image - transform = transforms.Compose( - [ - transforms.ToTensor(), - transforms.Normalize( - (0.1307,), (0.3081,) - ), # Mean and Std Deviation for MNIST - ] - ) + transform = transforms.Compose([ + transforms.ToTensor(), + transforms.Normalize((0.1307,), (0.3081,)), # Mean and Std Deviation for MNIST + ]) # Loading MNIST dataset dataset = datasets.MNIST( @@ -83,8 +79,7 @@ def training_pipeline( if previous_pipeline_directory is not None: if (Path(previous_pipeline_directory) / "checkpoint.pt").exists(): states = torch.load( - Path(previous_pipeline_directory) / "checkpoint.pt", - weights_only=False + Path(previous_pipeline_directory) / "checkpoint.pt", weights_only=False ) model = states["model"] optimizer = states["optimizer"] @@ -153,21 +148,20 @@ def training_pipeline( if __name__ == "__main__": logging.basicConfig(level=logging.INFO) - pipeline_space = { - "learning_rate": neps.Float(1e-5, 1e-1, log=True), - "num_layers": neps.Integer(1, 5), - "num_neurons": neps.Integer(64, 128), - "weight_decay": neps.Float(1e-5, 0.1, log=True), - "epochs": neps.Integer(1, 10, is_fidelity=True), - } + class ModelSpace(neps.PipelineSpace): + learning_rate = neps.Float(1e-5, 1e-1, log=True) + num_layers = neps.Integer(1, 5) + num_neurons = neps.Integer(64, 128) + weight_decay = neps.Float(1e-5, 0.1, log=True) + epochs = neps.Fidelity(neps.Integer(1, 10)) neps.run( - pipeline_space=pipeline_space, + pipeline_space=ModelSpace(), evaluate_pipeline=training_pipeline, optimizer="ifbo", fidelities_to_spend=50, root_directory="./results/ifbo-mnist/", - overwrite_working_directory=False, # set to False for a multi-worker run + overwrite_root_directory=False, # set to False for a multi-worker run ) # NOTE: this is `experimental` and may not work as expected diff --git a/neps_examples/real_world/image_segmentation_hpo.py b/neps_examples/real_world/image_segmentation_hpo.py index 51ff27a06..67dd933cf 100644 --- a/neps_examples/real_world/image_segmentation_hpo.py +++ b/neps_examples/real_world/image_segmentation_hpo.py @@ -21,27 +21,33 @@ def __init__(self, iters_per_epoch, lr, momentum, weight_decay): def training_step(self, batch): images, targets = batch - outputs = self.model(images)['out'] + outputs = self.model(images)["out"] loss = self.loss_fn(outputs, targets.long().squeeze(1)) self.log("train_loss", loss, sync_dist=True) return loss def validation_step(self, batch): images, targets = batch - outputs = self.model(images)['out'] + outputs = self.model(images)["out"] loss = self.loss_fn(outputs, targets.long().squeeze(1)) self.log("val_loss", loss, sync_dist=True) return loss def configure_optimizers(self): - optimizer = torch.optim.SGD(self.model.parameters(), lr=self.lr, momentum=self.momentum, weight_decay=self.weight_decay) + optimizer = torch.optim.SGD( + self.model.parameters(), + lr=self.lr, + momentum=self.momentum, + weight_decay=self.weight_decay, + ) scheduler = PolynomialLR( - optimizer, total_iters=self.iters_per_epoch * self.trainer.max_epochs, power=0.9 + optimizer, + total_iters=self.iters_per_epoch * self.trainer.max_epochs, + power=0.9, ) return [optimizer], [scheduler] - class SegmentationData(L.LightningDataModule): def __init__(self, batch_size=4): super().__init__() @@ -56,29 +62,62 @@ def train_dataloader(self): transform = transforms.Compose([ transforms.ToTensor(), transforms.Resize((256, 256), antialias=True), - transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) + transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) - target_transform = transforms.Compose([transforms.ToTensor(), transforms.Resize((256, 256), antialias=True)]) - train_dataset = datasets.VOCSegmentation(root=".data/VOC", transform=transform, target_transform=target_transform) - return torch.utils.data.DataLoader(train_dataset, batch_size=self.batch_size, shuffle=True, num_workers=16, persistent_workers=True) + target_transform = transforms.Compose( + [transforms.ToTensor(), transforms.Resize((256, 256), antialias=True)] + ) + train_dataset = datasets.VOCSegmentation( + root=".data/VOC", transform=transform, target_transform=target_transform + ) + return torch.utils.data.DataLoader( + train_dataset, + batch_size=self.batch_size, + shuffle=True, + num_workers=16, + persistent_workers=True, + ) def val_dataloader(self): transform = transforms.Compose([ transforms.ToTensor(), transforms.Resize((256, 256), antialias=True), - transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) + transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) - target_transform = transforms.Compose([transforms.ToTensor(), transforms.Resize((256, 256), antialias=True)]) - val_dataset = datasets.VOCSegmentation(root=".data/VOC", year='2012', image_set='val', transform=transform, target_transform=target_transform) - return torch.utils.data.DataLoader(val_dataset, batch_size=self.batch_size, shuffle=False, num_workers=16, persistent_workers=True) + target_transform = transforms.Compose( + [transforms.ToTensor(), transforms.Resize((256, 256), antialias=True)] + ) + val_dataset = datasets.VOCSegmentation( + root=".data/VOC", + year="2012", + image_set="val", + transform=transform, + target_transform=target_transform, + ) + return torch.utils.data.DataLoader( + val_dataset, + batch_size=self.batch_size, + shuffle=False, + num_workers=16, + persistent_workers=True, + ) def evaluate_pipeline(**kwargs): data = SegmentationData(kwargs.get("batch_size", 4)) data.prepare_data() iters_per_epoch = len(data.train_dataloader()) - model = LitSegmentation(iters_per_epoch, kwargs.get("lr", 0.02), kwargs.get("momentum", 0.9), kwargs.get("weight_decay", 1e-4)) - trainer = L.Trainer(max_epochs=kwargs.get("epoch", 30), strategy=DDPStrategy(find_unused_parameters=True), enable_checkpointing=False) + model = LitSegmentation( + iters_per_epoch, + kwargs.get("lr", 0.02), + kwargs.get("momentum", 0.9), + kwargs.get("weight_decay", 1e-4), + ) + trainer = L.Trainer( + max_epochs=kwargs.get("epoch", 30), + strategy=DDPStrategy(find_unused_parameters=True), + enable_checkpointing=False, + ) trainer.fit(model, data) val_loss = trainer.logged_metrics["val_loss"].detach().item() return val_loss @@ -92,33 +131,11 @@ def evaluate_pipeline(**kwargs): # Search space for hyperparameters pipeline_space = dict( - lr=neps.Float( - lower=0.0001, - upper=0.1, - log=True, - prior=0.02 - ), - momentum=neps.Float( - lower=0.1, - upper=0.9, - prior=0.5 - ), - weight_decay=neps.Float( - lower=1e-5, - upper=1e-3, - log=True, - prior=1e-4 - ), - epoch=neps.Integer( - lower=10, - upper=30, - is_fidelity=True - ), - batch_size=neps.Integer( - lower=4, - upper=12, - prior=4 - ), + lr=neps.HPOFloat(lower=0.0001, upper=0.1, log=True, prior=0.02), + momentum=neps.HPOFloat(lower=0.1, upper=0.9, prior=0.5), + weight_decay=neps.HPOFloat(lower=1e-5, upper=1e-3, log=True, prior=1e-4), + epoch=neps.HPOInteger(lower=10, upper=30, is_fidelity=True), + batch_size=neps.HPOInteger(lower=4, upper=12, prior=4), ) neps.run( diff --git a/pyproject.toml b/pyproject.toml index 9611a16e9..a85c35afb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ dev = [ "mypy>=1,<2", "pytest>=7,<8", "pytest-cases>=3,<4", + "pytest-repeat>=0,<1", "types-PyYAML>=6,<7", "mkdocs-material", "mkdocs-autorefs", @@ -241,6 +242,10 @@ ignore = [ "PT011", # Catch value error to broad "ARG001", # unused param ] +"tests/test_neps_space/*.py" = [ + "E501", # Line length for architecture strings +] + "__init__.py" = ["I002"] "neps_examples/*" = [ "INP001", diff --git a/tests/test_config_encoder.py b/tests/test_config_encoder.py index db4a5cee6..276bc566e 100644 --- a/tests/test_config_encoder.py +++ b/tests/test_config_encoder.py @@ -2,14 +2,14 @@ import torch -from neps.space import Categorical, ConfigEncoder, Float, Integer +from neps.space import ConfigEncoder, HPOCategorical, HPOFloat, HPOInteger def test_config_encoder_pdist_calculation() -> None: parameters = { - "a": Categorical(["cat", "mouse", "dog"]), - "b": Integer(1, 10), - "c": Float(1, 10), + "a": HPOCategorical(["cat", "mouse", "dog"]), + "b": HPOInteger(1, 10), + "c": HPOFloat(1, 10), } encoder = ConfigEncoder.from_parameters(parameters) config1 = {"a": "cat", "b": 1, "c": 1.0} @@ -43,9 +43,9 @@ def test_config_encoder_pdist_calculation() -> None: def test_config_encoder_pdist_squareform() -> None: parameters = { - "a": Categorical(["cat", "mouse", "dog"]), - "b": Integer(1, 10), - "c": Float(1, 10), + "a": HPOCategorical(["cat", "mouse", "dog"]), + "b": HPOInteger(1, 10), + "c": HPOFloat(1, 10), } encoder = ConfigEncoder.from_parameters(parameters) config1 = {"a": "cat", "b": 1, "c": 1.0} diff --git a/tests/test_neps_space/__init__.py b/tests/test_neps_space/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_neps_space/test_basic_functionality.py b/tests/test_neps_space/test_basic_functionality.py new file mode 100644 index 000000000..c8c2d5f93 --- /dev/null +++ b/tests/test_neps_space/test_basic_functionality.py @@ -0,0 +1,163 @@ +"""Simplified tests for basic NePS functionality.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +import neps +from neps.optimizers import algorithms +from neps.space.neps_spaces.parameters import ( + Float, + Integer, + PipelineSpace, +) + + +class SimpleSpace(PipelineSpace): + """Simple space for testing.""" + + x = Float(min_value=0.0, max_value=1.0) + y = Integer(min_value=1, max_value=10) + + +def simple_evaluation(x: float, y: int) -> float: + """Simple evaluation function.""" + return x + y + + +def test_basic_neps_run(): + """Test that basic NePS run functionality works.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "basic_test" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=3, + overwrite_root_directory=True, + ) + + # Check that optimization ran and created some files + assert root_directory.exists() + + # Should have created some evaluation files + files = list(root_directory.rglob("*")) + assert len(files) > 0, "Should have created some files" + + +def test_neps_optimization_with_dict_return(): + """Test NePS optimization with evaluation function returning dict.""" + + def dict_evaluation(x: float, y: int) -> dict: + return { + "objective_to_minimize": x + y, + "additional_metric": x * y, + } + + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "dict_test" + + # Run optimization + neps.run( + evaluate_pipeline=dict_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=3, + overwrite_root_directory=True, + ) + + # Check that optimization completed + assert root_directory.exists() + + +def test_different_neps_optimizers(): + """Test that different NePS optimizers work.""" + optimizers_to_test = [ + algorithms.neps_random_search, + algorithms.complex_random_search, + ] + + for optimizer in optimizers_to_test: + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / f"optimizer_{optimizer.__name__}" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=optimizer, + root_directory=str(root_directory), + evaluations_to_spend=3, + overwrite_root_directory=True, + ) + + # Check that optimization completed + assert root_directory.exists() + + +def test_neps_status_functionality(): + """Test that neps.status works after optimization.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "status_test" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=5, + overwrite_root_directory=True, + ) + + # Test status functionality (should not raise an error) + try: + neps.status(str(root_directory)) + except (FileNotFoundError, ValueError, KeyError) as e: + pytest.fail(f"neps.status should work after optimization: {e}") + + +def test_evaluation_results_are_recorded(): + """Test that evaluation results are properly recorded.""" + # Track evaluations + evaluations_called = [] + + def tracking_evaluation(x: float, y: int) -> float: + result = x + y + evaluations_called.append((x, y, result)) + return result + + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "tracking_test" + + # Run optimization + neps.run( + evaluate_pipeline=tracking_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=3, + overwrite_root_directory=True, + ) + + # Check that evaluations were called + assert len(evaluations_called) == 3, ( + f"Expected 3 evaluations, got {len(evaluations_called)}" + ) + + # Check that all results are reasonable + for x, y, result in evaluations_called: + assert 0.0 <= x <= 1.0, f"x should be in [0,1], got {x}" + assert 1 <= y <= 10, f"y should be in [1,10], got {y}" + assert result == x + y, f"Result should be x+y, got {result} != {x}+{y}" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_neps_space/test_domain__centering.py b/tests/test_neps_space/test_domain__centering.py new file mode 100644 index 000000000..37850427d --- /dev/null +++ b/tests/test_neps_space/test_domain__centering.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import pytest + +from neps.space.neps_spaces.parameters import Categorical, ConfidenceLevel, Float, Integer + + +@pytest.mark.parametrize( + ("confidence_level", "expected_prior_min_max"), + [ + (ConfidenceLevel.LOW, (50, 10, 90)), + (ConfidenceLevel.MEDIUM, (50, 25, 75)), + (ConfidenceLevel.HIGH, (50, 40, 60)), + ], +) +def test_centering_integer( + confidence_level, + expected_prior_min_max, +): + # Construct domains manually and then with priors. + # They are constructed in a way that after centering they both + # refer to identical domain ranges. + + int_prior = 50 + + int1 = Integer( + min_value=1, + max_value=100, + ) + int2 = Integer( + min_value=1, + max_value=100, + prior=int_prior, + prior_confidence=confidence_level, + ) + + int1_centered = int1.centered_around(int_prior, confidence_level) + int2_centered = int2.centered_around(int2.prior, int2.prior_confidence) + + assert int_prior == expected_prior_min_max[0] + assert ( + ( + int1_centered.prior, + int1_centered.min_value, + int1_centered.max_value, + ) + == ( + int2_centered.prior, + int2_centered.min_value, + int2_centered.max_value, + ) + == expected_prior_min_max + ) + + int1_centered.sample() + int2_centered.sample() + + +@pytest.mark.parametrize( + ("confidence_level", "expected_prior_min_max"), + [ + ( + ConfidenceLevel.LOW, + (50.0, 10.399999999999999, 89.6), + ), + (ConfidenceLevel.MEDIUM, (50.0, 25.25, 74.75)), + (ConfidenceLevel.HIGH, (50.0, 40.1, 59.9)), + ], +) +def test_centering_float( + confidence_level, + expected_prior_min_max, +): + # Construct domains manually and then with priors. + # They are constructed in a way that after centering they both + # refer to identical domain ranges. + + float_prior = 50.0 + + float1 = Float( + min_value=1.0, + max_value=100.0, + ) + float2 = Float( + min_value=1.0, + max_value=100.0, + prior=float_prior, + prior_confidence=confidence_level, + ) + + float1_centered = float1.centered_around(float_prior, confidence_level) + float2_centered = float2.centered_around(float2.prior, float2.prior_confidence) + + assert float_prior == expected_prior_min_max[0] + assert ( + ( + float1_centered.prior, + float1_centered.min_value, + float1_centered.max_value, + ) + == ( + float2_centered.prior, + float2_centered.min_value, + float2_centered.max_value, + ) + == expected_prior_min_max + ) + + float1_centered.sample() + float2_centered.sample() + + +@pytest.mark.parametrize( + ("confidence_level", "expected_prior_min_max_value"), + [ + (ConfidenceLevel.LOW, (40, 0, 80, 50)), + (ConfidenceLevel.MEDIUM, (25, 0, 50, 50)), + (ConfidenceLevel.HIGH, (10, 0, 20, 50)), + ], +) +def test_centering_categorical( + confidence_level, + expected_prior_min_max_value, +): + # Construct domains manually and then with priors. + # They are constructed in a way that after centering they both + # refer to identical domain ranges. + + categorical_prior_index_original = 49 + + categorical1 = Categorical( + choices=tuple(range(1, 101)), + ) + categorical2 = Categorical( + choices=tuple(range(1, 101)), + prior=categorical_prior_index_original, + prior_confidence=confidence_level, + ) + + categorical1_centered = categorical1.centered_around( + categorical_prior_index_original, confidence_level + ) + categorical2_centered = categorical2.centered_around( + categorical2.prior, categorical2.prior_confidence + ) + + # During the centering of categorical objects, the prior index will change. + assert categorical_prior_index_original != expected_prior_min_max_value[0] + + assert ( + ( + categorical1_centered.prior, + categorical1_centered.min_value, + categorical1_centered.max_value, + categorical1_centered.choices[categorical1_centered.prior], + ) + == ( + categorical2_centered.prior, + categorical2_centered.min_value, + categorical2_centered.max_value, + categorical2_centered.choices[categorical2_centered.prior], + ) + == expected_prior_min_max_value + ) + + categorical1_centered.sample() + categorical2_centered.sample() + + +@pytest.mark.parametrize( + ("confidence_level", "expected_prior_min_max"), + [ + (ConfidenceLevel.LOW, (10, 5, 13)), + (ConfidenceLevel.MEDIUM, (10, 7, 13)), + (ConfidenceLevel.HIGH, (10, 8, 12)), + ], +) +def test_centering_stranger_ranges_integer( + confidence_level, + expected_prior_min_max, +): + int1 = Integer( + min_value=1, + max_value=13, + ) + int1_centered = int1.centered_around(10, confidence_level) + + int2 = Integer( + min_value=1, + max_value=13, + prior=10, + prior_confidence=confidence_level, + ) + int2_centered = int2.centered_around(int2.prior, int2.prior_confidence) + + assert ( + int1_centered.prior, + int1_centered.min_value, + int1_centered.max_value, + ) == expected_prior_min_max + assert ( + int2_centered.prior, + int2_centered.min_value, + int2_centered.max_value, + ) == expected_prior_min_max + + int1_centered.sample() + int2_centered.sample() + + +@pytest.mark.parametrize( + ("confidence_level", "expected_prior_min_max"), + [ + ( + ConfidenceLevel.LOW, + (0.5, 0.09999999999999998, 0.9), + ), + (ConfidenceLevel.MEDIUM, (0.5, 0.25, 0.75)), + (ConfidenceLevel.HIGH, (0.5, 0.4, 0.6)), + ], +) +def test_centering_stranger_ranges_float( + confidence_level, + expected_prior_min_max, +): + float1 = Float( + min_value=0.0, + max_value=1.0, + ) + float1_centered = float1.centered_around(0.5, confidence_level) + + float2 = Float( + min_value=0.0, + max_value=1.0, + prior=0.5, + prior_confidence=confidence_level, + ) + float2_centered = float2.centered_around(float2.prior, float2.prior_confidence) + + assert ( + float1_centered.prior, + float1_centered.min_value, + float1_centered.max_value, + ) == expected_prior_min_max + assert ( + float2_centered.prior, + float2_centered.min_value, + float2_centered.max_value, + ) == expected_prior_min_max + + float1_centered.sample() + float2_centered.sample() + + +@pytest.mark.parametrize( + ("confidence_level", "expected_prior_min_max_value"), + [ + (ConfidenceLevel.LOW, (2, 0, 5, 2)), + (ConfidenceLevel.MEDIUM, (2, 0, 4, 2)), + (ConfidenceLevel.HIGH, (1, 0, 2, 2)), + ], +) +def test_centering_stranger_ranges_categorical( + confidence_level, + expected_prior_min_max_value, +): + categorical1 = Categorical( + choices=tuple(range(7)), + ) + categorical1_centered = categorical1.centered_around(2, confidence_level) + + categorical2 = Categorical( + choices=tuple(range(7)), + prior=2, + prior_confidence=confidence_level, + ) + categorical2_centered = categorical2.centered_around( + categorical2.prior, categorical2.prior_confidence + ) + + assert ( + categorical1_centered.prior, + categorical1_centered.min_value, + categorical1_centered.max_value, + categorical1_centered.choices[categorical1_centered.prior], + ) == expected_prior_min_max_value + + assert ( + categorical2_centered.prior, + categorical2_centered.min_value, + categorical2_centered.max_value, + categorical2_centered.choices[categorical2_centered.prior], + ) == expected_prior_min_max_value + + categorical1_centered.sample() + categorical2_centered.sample() diff --git a/tests/test_neps_space/test_neps_integration.py b/tests/test_neps_space/test_neps_integration.py new file mode 100644 index 000000000..adc4cb55a --- /dev/null +++ b/tests/test_neps_space/test_neps_integration.py @@ -0,0 +1,584 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from functools import partial + +import pytest + +import neps +import neps.optimizers +from neps.optimizers import algorithms +from neps.space.neps_spaces.neps_space import ( + check_neps_space_compatibility, + convert_classic_to_neps_search_space, + convert_neps_to_classic_search_space, +) +from neps.space.neps_spaces.parameters import ( + Categorical, + ConfidenceLevel, + Fidelity, + Float, + Integer, + Operation, + PipelineSpace, + Resampled, +) + + +def hyperparameter_pipeline_to_optimize( + float1: float, + float2: float, + categorical: int, + integer1: int, + integer2: int, +): + assert isinstance(float1, float) + assert isinstance(float2, float) + assert isinstance(categorical, int) + assert isinstance(integer1, int) + assert isinstance(integer2, int) + + objective_to_minimize = -float(float1 + float2 + categorical + integer1 + integer2) + assert isinstance(objective_to_minimize, float) + + return objective_to_minimize + + +class DemoHyperparameterSpace(PipelineSpace): + float1 = Float( + min_value=0, + max_value=1, + prior=0.1, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + float2 = Float( + min_value=-10, + max_value=10, + prior=0.1, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + categorical = Categorical( + choices=(0, 1), + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + integer1 = Integer( + min_value=0, + max_value=1, + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + integer2 = Integer( + min_value=1, + max_value=1000, + prior=10, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + + +class DemoHyperparameterWithFidelitySpace(PipelineSpace): + float1 = Float( + min_value=0, + max_value=1, + prior=0.1, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + float2 = Float( + min_value=-10, + max_value=10, + prior=0.1, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + categorical = Categorical( + choices=(0, 1), + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + integer1 = Integer( + min_value=0, + max_value=1, + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + integer2 = Fidelity( + Integer( + min_value=1, + max_value=1000, + ), + ) + + +class DemoHyperparameterComplexSpace(PipelineSpace): + _small_float = Float( + min_value=0, + max_value=1, + prior=0.1, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + _big_float = Float( + min_value=10, + max_value=100, + prior=20, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + + float1 = Categorical( + choices=( + Resampled(_small_float), + Resampled(_big_float), + ), + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + float2 = Categorical( + choices=( + Resampled(_small_float), + Resampled(_big_float), + float1, + ), + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + categorical = Categorical( + choices=(0, 1), + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + integer1 = Integer( + min_value=0, + max_value=1, + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + integer2 = Integer( + min_value=1, + max_value=1000, + prior=10, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + + +@pytest.mark.parametrize( + "optimizer", + [ + partial(algorithms.neps_random_search, ignore_fidelity=True), + partial(algorithms.complex_random_search, ignore_fidelity=True), + ], +) +def test_hyperparameter_demo(optimizer): + pipeline_space = DemoHyperparameterSpace() + root_directory = f"/tests_tmpdir/test_neps_spaces/results/hyperparameter_demo__{optimizer.func.__name__}" + + neps.run( + evaluate_pipeline=hyperparameter_pipeline_to_optimize, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + evaluations_to_spend=10, + overwrite_root_directory=True, + ) + neps.status(root_directory, print_summary=True) + + +@pytest.mark.parametrize( + "optimizer", + [ + partial(algorithms.neps_random_search, ignore_fidelity=True), + partial(algorithms.complex_random_search, ignore_fidelity=True), + ], +) +def test_hyperparameter_with_fidelity_demo(optimizer): + pipeline_space = DemoHyperparameterWithFidelitySpace() + root_directory = f"/tests_tmpdir/test_neps_spaces/results/hyperparameter_with_fidelity_demo__{optimizer.func.__name__}" + + neps.run( + evaluate_pipeline=hyperparameter_pipeline_to_optimize, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + evaluations_to_spend=10, + overwrite_root_directory=True, + ) + neps.status(root_directory, print_summary=True) + + +@pytest.mark.parametrize( + "optimizer", + [ + partial(algorithms.neps_random_search, ignore_fidelity=True), + partial(algorithms.complex_random_search, ignore_fidelity=True), + ], +) +def test_hyperparameter_complex_demo(optimizer): + pipeline_space = DemoHyperparameterComplexSpace() + root_directory = f"/tests_tmpdir/test_neps_spaces/results/hyperparameter_complex_demo__{optimizer.func.__name__}" + + neps.run( + evaluate_pipeline=hyperparameter_pipeline_to_optimize, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + overwrite_root_directory=True, + evaluations_to_spend=10, + ) + neps.status(root_directory, print_summary=True) + + +# ----------------------------------------- + + +class Model: + """A simple model that takes an inner function and a factor, + multiplies the result of the inner function by the factor. + """ + + def __init__( + self, + inner_function: Callable[[Sequence[float]], float], + factor: float, + ): + """Initialize the model with an inner function and a factor.""" + self.inner_function = inner_function + self.factor = factor + + def __call__(self, values: Sequence[float]) -> float: + return self.factor * self.inner_function(values) + + +class Sum: + """A simple inner function that sums the values.""" + + def __call__(self, values: Sequence[float]) -> float: + return sum(values) + + +class MultipliedSum: + """An inner function that sums the values and multiplies the result by a factor.""" + + def __init__(self, factor: float): + """Initialize the multiplied sum with a factor.""" + self.factor = factor + + def __call__(self, values: Sequence[float]) -> float: + return self.factor * sum(values) + + +def operation_pipeline_to_optimize(model: Model, some_hp: str): + assert isinstance(model, Model) + assert isinstance(model.factor, float) + assert isinstance(model.inner_function, Sum | MultipliedSum) + if isinstance(model.inner_function, MultipliedSum): + assert isinstance(model.inner_function.factor, float) + assert some_hp in {"hp1", "hp2"} + + values = list(range(1, 21)) + objective_to_minimize = model(values) + assert isinstance(objective_to_minimize, float) + + return objective_to_minimize + + +class DemoOperationSpace(PipelineSpace): + """A demonstration of how to use operations in a search space. + This space defines a model that can be optimized using different inner functions + and a factor. The model can be used to evaluate a set of values and return an objective to minimize. + """ + + # The way to sample `factor` values + _factor = Float( + min_value=0, + max_value=1, + prior=0.1, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + + # Sum + # Will be equivalent to something like + # `Sum()` + # Could have also been defined using the python `sum` function as + # `_sum = space.Operation(operator=lambda: sum)` + _sum = Operation(operator=Sum) + + # MultipliedSum + # Will be equivalent to something like + # `MultipliedSum(factor=0.2)` + _multiplied_sum = Operation( + operator=MultipliedSum, + kwargs={"factor": Resampled(_factor)}, + ) + + # Model + # Will be equivalent to something like one of + # `Model(Sum(), factor=0.1)` + # `Model(MultipliedSum(factor=0.2), factor=0.1)` + _inner_function = Categorical( + choices=(_sum, _multiplied_sum), + ) + model = Operation( + operator=Model, + args=(_inner_function,), + kwargs={"factor": Resampled(_factor)}, + ) + + # An additional hyperparameter + some_hp = Categorical( + choices=("hp1", "hp2"), + ) + + +@pytest.mark.parametrize( + "optimizer", + [ + algorithms.neps_random_search, + algorithms.complex_random_search, + ], +) +def test_operation_demo(optimizer): + pipeline_space = DemoOperationSpace() + root_directory = ( + f"/tests_tmpdir/test_neps_spaces/results/operation_demo__{optimizer.__name__}" + ) + + neps.run( + evaluate_pipeline=operation_pipeline_to_optimize, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + evaluations_to_spend=10, + overwrite_root_directory=True, + ) + neps.status(root_directory, print_summary=True) + + +# ===== Extended tests for newer NePS features ===== + + +# Test neps_hyperband with various PipelineSpaces +@pytest.mark.parametrize( + "optimizer", + [ + algorithms.neps_hyperband, + ], +) +def test_neps_hyperband_with_fidelity_demo(optimizer): + """Test neps_hyperband with a fidelity space.""" + pipeline_space = DemoHyperparameterWithFidelitySpace() + root_directory = f"/tests_tmpdir/test_neps_spaces/results/neps_hyperband_fidelity_demo__{optimizer.__name__}" + + neps.run( + evaluate_pipeline=hyperparameter_pipeline_to_optimize, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + fidelities_to_spend=15, # Use fidelities_to_spend for multi-fidelity optimizers + overwrite_root_directory=True, + ) + neps.status(root_directory, print_summary=True) + + +# Test PipelineSpace dynamic methods (add, remove, add_prior) +def test_pipeline_space_dynamic_methods(): + """Test PipelineSpace add, remove, and add_prior methods.""" + + # Create a basic space + class BasicSpace(PipelineSpace): + x = Float(min_value=0.0, max_value=1.0) + y = Integer(min_value=1, max_value=10) + + space = BasicSpace() + + # Test adding a new parameter + new_param = Categorical(choices=(True, False)) + space = space.add(new_param, "flag") + + # Verify the parameter was added + attrs = space.get_attrs() + assert "flag" in attrs + assert attrs["flag"] is new_param + + # Test adding a prior to an existing parameter + space = space.add_prior("x", prior=0.5, prior_confidence=ConfidenceLevel.HIGH) + + # Verify the prior was added + updated_attrs = space.get_attrs() + x_param = updated_attrs["x"] + assert x_param.has_prior + assert x_param.prior == 0.5 + assert x_param.prior_confidence == ConfidenceLevel.HIGH + + # Test removing a parameter + space = space.remove("y") + + # Verify the parameter was removed + final_attrs = space.get_attrs() + assert "y" not in final_attrs + assert "x" in final_attrs + assert "flag" in final_attrs + + +# Test space conversion functions +def test_space_conversion_functions(): + """Test conversion between classic and NePS spaces.""" + # Create a classic SearchSpace + classic_space = neps.SearchSpace( + { + "x": neps.HPOFloat(0.0, 1.0, prior=0.5, prior_confidence="medium"), + "y": neps.HPOInteger(1, 10, prior=5, prior_confidence="high"), + "z": neps.HPOCategorical(["a", "b", "c"], prior="b", prior_confidence="low"), + } + ) + + # Convert to NePS space + neps_space = convert_classic_to_neps_search_space(classic_space) + assert isinstance(neps_space, PipelineSpace) + + # Verify attributes are preserved + neps_attrs = neps_space.get_attrs() + assert len(neps_attrs) == 3 + assert all(name in neps_attrs for name in ["x", "y", "z"]) + + # Verify types and priors + assert isinstance(neps_attrs["x"], Float) + assert neps_attrs["x"].has_prior + assert neps_attrs["x"].prior == 0.5 + + assert isinstance(neps_attrs["y"], Integer) + assert neps_attrs["y"].has_prior + assert neps_attrs["y"].prior == 5 + + assert isinstance(neps_attrs["z"], Categorical) + assert neps_attrs["z"].has_prior + assert neps_attrs["z"].prior == 1 # Index of "b" in choices + + # Convert back to classic space + converted_back = convert_neps_to_classic_search_space(neps_space) + assert converted_back is not None + assert isinstance(converted_back, neps.SearchSpace) + + # Verify round-trip conversion preserves structure + classic_attrs = converted_back.elements + assert len(classic_attrs) == 3 + assert all(name in classic_attrs for name in ["x", "y", "z"]) + + +# Test algorithm compatibility checking +def test_algorithm_compatibility(): + """Test algorithm compatibility with different space types.""" + # Test NePS-only algorithms + neps_only_algorithms = [ + algorithms.neps_random_search, + algorithms.neps_hyperband, + algorithms.complex_random_search, + ] + + for algo in neps_only_algorithms: + compatibility = check_neps_space_compatibility(algo) + assert compatibility in [ + "neps", + "both", + ], f"Algorithm {algo.__name__} should be neps or both compatible" + + # Test classic algorithms that should work with both + both_compatible_algorithms = [ + algorithms.random_search, + algorithms.hyperband, + ] + + for algo in both_compatible_algorithms: + compatibility = check_neps_space_compatibility(algo) + assert compatibility in [ + "classic", + "both", + ], f"Algorithm {algo.__name__} should be classic or both compatible" + + +# Test with complex PipelineSpace containing Operations and Resampled +def test_complex_neps_space_features(): + """Test complex NePS space features that cannot be converted to classic.""" + + class ComplexNepsSpace(PipelineSpace): + # Basic parameters + factor = Float( + min_value=0.1, + max_value=2.0, + prior=1.0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + + # Operation with resampled parameters + operation = Operation( + operator=lambda x, y: x * y, + args=(factor, Resampled(factor)), + ) + + # Categorical with operations as choices + choice = Categorical( + choices=(operation, factor), + prior=0, + prior_confidence=ConfidenceLevel.LOW, + ) + + space = ComplexNepsSpace() + + # This space should NOT be convertible to classic + converted = convert_neps_to_classic_search_space(space) + assert converted is None, "Complex NePS space should not be convertible to classic" + + # But should work with NePS-compatible algorithms + compatibility = check_neps_space_compatibility(algorithms.neps_random_search) + assert compatibility in ["neps", "both"] + + +# Test trajectory and metrics functionality +def test_trajectory_and_metrics(tmp_path): + """Test extended trajectory and best_config functionality.""" + + def evaluate_with_metrics(x: float, y: int) -> dict: + """Evaluation function that returns multiple metrics.""" + return { + "objective_to_minimize": x + y, + "accuracy": 1.0 - (x + y) / 11.0, # Dummy accuracy metric + "training_time": x * 10, # Dummy training time + "memory_usage": y * 100, # Dummy memory usage + } + + class MetricsSpace(PipelineSpace): + x = Float(min_value=0.0, max_value=1.0) + y = Integer(min_value=1, max_value=10) + + space = MetricsSpace() + root_directory = tmp_path / "metrics_test" + + # Run optimization + neps.run( + evaluate_pipeline=evaluate_with_metrics, + pipeline_space=space, + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=5, + overwrite_root_directory=True, + ) + + # Check that trajectory and best_config files exist and contain extended metrics + trajectory_file = root_directory / "summary" / "best_config_trajectory.txt" + best_config_file = root_directory / "summary" / "best_config.txt" + + assert trajectory_file.exists(), "Trajectory file should exist" + assert best_config_file.exists(), "Best config file should exist" + + # Read and verify trajectory contains the standard format (not extended metrics in txt files) + trajectory_content = trajectory_file.read_text() + assert "Config ID:" in trajectory_content, "Trajectory should contain Config ID" + assert "Objective to minimize:" in trajectory_content, ( + "Trajectory should contain objective" + ) + assert "Cumulative evaluations:" in trajectory_content, ( + "Trajectory should contain cumulative evaluations" + ) + + # Read and verify best config contains the standard format + best_config_content = best_config_file.read_text() + assert "Config ID:" in best_config_content, "Best config should contain Config ID" + assert "Objective to minimize:" in best_config_content, ( + "Best config should contain objective" + ) diff --git a/tests/test_neps_space/test_neps_integration_priorband__max_cost.py b/tests/test_neps_space/test_neps_integration_priorband__max_cost.py new file mode 100644 index 000000000..eda19c37f --- /dev/null +++ b/tests/test_neps_space/test_neps_integration_priorband__max_cost.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from functools import partial + +import numpy as np +import pytest + +import neps +from neps import algorithms +from neps.space.neps_spaces.parameters import ( + ConfidenceLevel, + Fidelity, + Float, + Integer, + PipelineSpace, +) + +_COSTS = {} + + +def evaluate_pipeline(float1, float2, integer1, fidelity): + objective_to_minimize = -float(np.sum([float1, float2, integer1])) * fidelity + + key = (float1, float2, integer1) + old_cost = _COSTS.get(key, 0) + added_cost = fidelity - old_cost + + _COSTS[key] = fidelity + + return { + "objective_to_minimize": objective_to_minimize, + "cost": added_cost, + } + + +class DemoHyperparameterWithFidelitySpace(PipelineSpace): + float1 = Float( + min_value=1, + max_value=1000, + log=False, + prior=600, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + float2 = Float( + min_value=-100, + max_value=100, + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + integer1 = Integer( + min_value=0, + max_value=500, + prior=35, + prior_confidence=ConfidenceLevel.LOW, + ) + fidelity = Fidelity( + domain=Integer( + min_value=1, + max_value=100, + ), + ) + + +@pytest.mark.parametrize( + ("optimizer", "optimizer_name"), + [ + ( + partial(algorithms.neps_random_search, ignore_fidelity=True), + "new__RandomSearch", + ), + ( + partial(algorithms.complex_random_search, ignore_fidelity=True), + "new__ComplexRandomSearch", + ), + ( + partial(algorithms.neps_priorband, base="successive_halving"), + "new__priorband+successive_halving", + ), + ( + partial(algorithms.neps_priorband, base="asha"), + "new__priorband+asha", + ), + ( + partial(algorithms.neps_priorband, base="async_hb"), + "new__priorband+async_hb", + ), + ( + algorithms.neps_priorband, + "new__priorband+hyperband", + ), + ], +) +def test_hyperparameter_with_fidelity_demo_new(optimizer, optimizer_name): + optimizer.__name__ = optimizer_name # Needed by NEPS later. + pipeline_space = DemoHyperparameterWithFidelitySpace() + root_directory = f"/tests_tmpdir/test_neps_spaces/results/hyperparameter_with_fidelity__costs__{optimizer.__name__}" + + # Reset the _COSTS global, so they do not get mixed up between tests. + _COSTS.clear() + + neps.run( + evaluate_pipeline=evaluate_pipeline, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + cost_to_spend=100, # Reduced from 1000 to make tests faster + overwrite_root_directory=True, + ) + neps.status(root_directory, print_summary=True) + + +@pytest.mark.parametrize( + ("optimizer", "optimizer_name"), + [ + ( + partial(algorithms.priorband, base="successive_halving"), + "old__priorband+successive_halving", + ), + ( + partial(algorithms.priorband, base="asha"), + "old__priorband+asha", + ), + ( + partial(algorithms.priorband, base="async_hb"), + "old__priorband+async_hb", + ), + ( + algorithms.priorband, + "old__priorband+hyperband", + ), + ], +) +def test_hyperparameter_with_fidelity_demo_old(optimizer, optimizer_name): + optimizer.__name__ = optimizer_name # Needed by NEPS later. + pipeline_space = DemoHyperparameterWithFidelitySpace() + root_directory = f"/tests_tmpdir/test_neps_spaces/results/hyperparameter_with_fidelity__costs__{optimizer.__name__}" + + # Reset the _COSTS global, so they do not get mixed up between tests. + _COSTS.clear() + + neps.run( + evaluate_pipeline=evaluate_pipeline, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + cost_to_spend=100, # Reduced from 1000 to make tests faster + overwrite_root_directory=True, + ) + neps.status(root_directory, print_summary=True) diff --git a/tests/test_neps_space/test_neps_integration_priorband__max_evals.py b/tests/test_neps_space/test_neps_integration_priorband__max_evals.py new file mode 100644 index 000000000..b95463cda --- /dev/null +++ b/tests/test_neps_space/test_neps_integration_priorband__max_evals.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from functools import partial + +import numpy as np +import pytest + +import neps +from neps.optimizers import algorithms +from neps.space.neps_spaces.parameters import ( + ConfidenceLevel, + Fidelity, + Float, + Integer, + PipelineSpace, +) + + +def evaluate_pipeline(float1, float2, integer1, fidelity): + return -float(np.sum([float1, float2, integer1])) * fidelity + + +class DemoHyperparameterWithFidelitySpace(PipelineSpace): + float1 = Float( + min_value=1, + max_value=1000, + log=False, + prior=600, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + float2 = Float( + min_value=-100, + max_value=100, + prior=0, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + integer1 = Integer( + min_value=0, + max_value=500, + prior=35, + prior_confidence=ConfidenceLevel.LOW, + ) + fidelity = Fidelity( + domain=Integer( + min_value=1, + max_value=100, + ), + ) + + +@pytest.mark.parametrize( + ("optimizer", "optimizer_name"), + [ + ( + partial(algorithms.neps_random_search, ignore_fidelity=True), + "new__RandomSearch", + ), + ( + partial(algorithms.complex_random_search, ignore_fidelity=True), + "new__ComplexRandomSearch", + ), + ( + partial(algorithms.neps_priorband, base="successive_halving"), + "new__priorband+successive_halving", + ), + ( + partial(algorithms.neps_priorband, base="asha"), + "new__priorband+asha", + ), + ( + partial(algorithms.neps_priorband, base="async_hb"), + "new__priorband+async_hb", + ), + ( + algorithms.neps_priorband, + "new__priorband+hyperband", + ), + ], +) +def test_hyperparameter_with_fidelity_demo_new(optimizer, optimizer_name): + optimizer.__name__ = optimizer_name # Needed by NEPS later. + pipeline_space = DemoHyperparameterWithFidelitySpace() + root_directory = f"/tests_tmpdir/test_neps_spaces/results/hyperparameter_with_fidelity__evals__{optimizer.__name__}" + + neps.run( + evaluate_pipeline=evaluate_pipeline, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + evaluations_to_spend=100, + overwrite_root_directory=True, + ) + neps.status(root_directory, print_summary=True) + + +@pytest.mark.parametrize( + ("optimizer", "optimizer_name"), + [ + ( + partial(algorithms.priorband, base="successive_halving"), + "old__priorband+successive_halving", + ), + ( + partial(algorithms.priorband, base="asha"), + "old__priorband+asha", + ), + ( + partial(algorithms.priorband, base="async_hb"), + "old__priorband+async_hb", + ), + ( + algorithms.priorband, + "old__priorband+hyperband", + ), + ], +) +def test_hyperparameter_with_fidelity_demo_old(optimizer, optimizer_name): + optimizer.__name__ = optimizer_name # Needed by NEPS later. + pipeline_space = DemoHyperparameterWithFidelitySpace() + root_directory = f"/tests_tmpdir/test_neps_spaces/results/hyperparameter_with_fidelity__evals__{optimizer.__name__}" + + neps.run( + evaluate_pipeline=evaluate_pipeline, + pipeline_space=pipeline_space, + optimizer=optimizer, + root_directory=root_directory, + evaluations_to_spend=100, + overwrite_root_directory=True, + ) + neps.status(root_directory, print_summary=True) diff --git a/tests/test_neps_space/test_pipeline_space_methods.py b/tests/test_neps_space/test_pipeline_space_methods.py new file mode 100644 index 000000000..5532257e9 --- /dev/null +++ b/tests/test_neps_space/test_pipeline_space_methods.py @@ -0,0 +1,396 @@ +"""Tests for PipelineSpace dynamic methods (add, remove, add_prior).""" + +from __future__ import annotations + +import pytest + +from neps.space.neps_spaces.parameters import ( + Categorical, + ConfidenceLevel, + Fidelity, + Float, + Integer, + Operation, + PipelineSpace, + Resampled, +) + + +class BasicSpace(PipelineSpace): + """Basic space for testing dynamic methods.""" + + x = Float(min_value=0.0, max_value=1.0) + y = Integer(min_value=1, max_value=10) + z = Categorical(choices=("a", "b", "c")) + + +class SpaceWithPriors(PipelineSpace): + """Space with existing priors for testing.""" + + x = Float( + min_value=0.0, max_value=1.0, prior=0.5, prior_confidence=ConfidenceLevel.MEDIUM + ) + y = Integer(min_value=1, max_value=10, prior=5, prior_confidence=ConfidenceLevel.HIGH) + z = Categorical( + choices=("a", "b", "c"), prior=1, prior_confidence=ConfidenceLevel.LOW + ) + + +# ===== Test add method ===== + + +def test_add_method_basic(): + """Test basic functionality of the add method.""" + space = BasicSpace() + original_attrs = space.get_attrs() + + # Add a new parameter + new_param = Float(min_value=10.0, max_value=20.0) + updated_space = space.add(new_param, "new_float") + + # Original space should be unchanged + assert space.get_attrs() == original_attrs + + # Updated space should have the new parameter + updated_attrs = updated_space.get_attrs() + assert "new_float" in updated_attrs + assert updated_attrs["new_float"] is new_param + assert len(updated_attrs) == len(original_attrs) + 1 + + +def test_add_method_different_types(): + """Test adding different parameter types.""" + space = BasicSpace() + + # Add Integer + space = space.add(Integer(min_value=0, max_value=100), "new_int") + assert "new_int" in space.get_attrs() + assert isinstance(space.get_attrs()["new_int"], Integer) + + # Add Categorical + space = space.add(Categorical(choices=(True, False)), "new_cat") + assert "new_cat" in space.get_attrs() + assert isinstance(space.get_attrs()["new_cat"], Categorical) + + # Add Operation + op = Operation(operator=lambda x: x * 2, args=(space.get_attrs()["x"],)) + space = space.add(op, "new_op") + assert "new_op" in space.get_attrs() + assert isinstance(space.get_attrs()["new_op"], Operation) + + # Add Resampled + resampled = Resampled(space.get_attrs()["x"]) + space = space.add(resampled, "new_resampled") + assert "new_resampled" in space.get_attrs() + assert isinstance(space.get_attrs()["new_resampled"], Resampled) + + +def test_add_method_with_default_name(): + """Test add method with automatic name generation.""" + space = BasicSpace() + original_count = len(space.get_attrs()) + + # Add without specifying name + new_param = Float(min_value=5.0, max_value=15.0) + updated_space = space.add(new_param) + + updated_attrs = updated_space.get_attrs() + assert len(updated_attrs) == original_count + 1 + + # Should have generated a name like "param_4" + generated_names = [name for name in updated_attrs if name.startswith("param_")] + assert len(generated_names) >= 1 + + +def test_add_method_duplicate_parameter(): + """Test adding a parameter with an existing name but same content.""" + space = BasicSpace() + + # Add the same parameter that already exists + existing_param = space.get_attrs()["x"] + updated_space = space.add(existing_param, "x") + + # Should work without error + assert updated_space.get_attrs()["x"] is existing_param + + +def test_add_method_conflicting_parameter(): + """Test adding a different parameter with an existing name.""" + space = BasicSpace() + + # Try to add a different parameter with existing name + different_param = Integer(min_value=0, max_value=5) # Different from existing "x" + + with pytest.raises(ValueError, match="A different parameter with the name"): + space.add(different_param, "x") + + +def test_add_method_chaining(): + """Test chaining multiple add operations.""" + space = BasicSpace() + + # Chain multiple additions + final_space = ( + space.add(Float(min_value=100.0, max_value=200.0), "param1") + .add(Integer(min_value=0, max_value=50), "param2") + .add(Categorical(choices=(1, 2, 3)), "param3") + ) + + attrs = final_space.get_attrs() + assert "param1" in attrs + assert "param2" in attrs + assert "param3" in attrs + assert len(attrs) == 6 # 3 original + 3 new + + +# ===== Test remove method ===== + + +def test_remove_method_basic(): + """Test basic functionality of the remove method.""" + space = BasicSpace() + original_attrs = space.get_attrs() + + # Remove a parameter + updated_space = space.remove("y") + + # Original space should be unchanged + assert space.get_attrs() == original_attrs + + # Updated space should not have the removed parameter + updated_attrs = updated_space.get_attrs() + assert "y" not in updated_attrs + assert "x" in updated_attrs + assert "z" in updated_attrs + assert len(updated_attrs) == len(original_attrs) - 1 + + +def test_remove_method_nonexistent_parameter(): + """Test removing a parameter that doesn't exist.""" + space = BasicSpace() + + with pytest.raises(ValueError, match="No parameter with the name"): + space.remove("nonexistent") + + +def test_remove_method_chaining(): + """Test chaining multiple remove operations.""" + space = BasicSpace() + + # Chain multiple removals + final_space = space.remove("x").remove("y") + + attrs = final_space.get_attrs() + assert "x" not in attrs + assert "y" not in attrs + assert "z" in attrs + assert len(attrs) == 1 + + +def test_remove_all_parameters(): + """Test removing all parameters from a space.""" + space = BasicSpace() + + # Remove all parameters + empty_space = space.remove("x").remove("y").remove("z") + + attrs = empty_space.get_attrs() + assert len(attrs) == 0 + + +# ===== Test add_prior method ===== + + +def test_add_prior_method_basic(): + """Test basic functionality of the add_prior method.""" + space = BasicSpace() + space.get_attrs() + + # Add prior to a parameter without prior + updated_space = space.add_prior("x", prior=0.5, prior_confidence=ConfidenceLevel.HIGH) + + # Original space should be unchanged + original_x = space.get_attrs()["x"] + assert not original_x.has_prior + + # Updated space should have the prior + updated_x = updated_space.get_attrs()["x"] + assert updated_x.has_prior + assert updated_x.prior == 0.5 + assert updated_x.prior_confidence == ConfidenceLevel.HIGH + + +def test_add_prior_method_different_types(): + """Test adding priors to different parameter types.""" + space = BasicSpace() + + # Add prior to Float + space = space.add_prior("x", prior=0.75, prior_confidence=ConfidenceLevel.MEDIUM) + x_param = space.get_attrs()["x"] + assert x_param.has_prior + assert x_param.prior == 0.75 + + # Add prior to Integer + space = space.add_prior("y", prior=7, prior_confidence=ConfidenceLevel.HIGH) + y_param = space.get_attrs()["y"] + assert y_param.has_prior + assert y_param.prior == 7 + + # Add prior to Categorical + space = space.add_prior("z", prior=2, prior_confidence=ConfidenceLevel.LOW) + z_param = space.get_attrs()["z"] + assert z_param.has_prior + assert z_param.prior == 2 + + +def test_add_prior_method_string_confidence(): + """Test add_prior with string confidence levels.""" + space = BasicSpace() + + # Test with string confidence levels + space = space.add_prior("x", prior=0.3, prior_confidence="low") + x_param = space.get_attrs()["x"] + assert x_param.has_prior + assert x_param.prior == 0.3 + assert x_param.prior_confidence == ConfidenceLevel.LOW + + space = space.add_prior("y", prior=8, prior_confidence="medium") + y_param = space.get_attrs()["y"] + assert y_param.prior_confidence == ConfidenceLevel.MEDIUM + + space = space.add_prior("z", prior=0, prior_confidence="high") + z_param = space.get_attrs()["z"] + assert z_param.prior_confidence == ConfidenceLevel.HIGH + + +def test_add_prior_method_nonexistent_parameter(): + """Test adding prior to a parameter that doesn't exist.""" + space = BasicSpace() + + with pytest.raises(ValueError, match="No parameter with the name"): + space.add_prior("nonexistent", prior=0.5, prior_confidence=ConfidenceLevel.MEDIUM) + + +def test_add_prior_method_already_has_prior(): + """Test adding prior to a parameter that already has one.""" + space = SpaceWithPriors() + + with pytest.raises(ValueError, match="already has a prior"): + space.add_prior("x", prior=0.8, prior_confidence=ConfidenceLevel.LOW) + + +def test_add_prior_method_unsupported_type(): + """Test adding prior to unsupported parameter types.""" + # Create space with an Operation (which doesn't support priors) + space = BasicSpace() + op = Operation(operator=lambda x: x * 2, args=(space.get_attrs()["x"],)) + space = space.add(op, "operation_param") + + with pytest.raises(ValueError, match="does not support priors"): + space.add_prior( + "operation_param", prior=0.5, prior_confidence=ConfidenceLevel.MEDIUM + ) + + +# ===== Test combined operations ===== + + +def test_combined_operations(): + """Test combining add, remove, and add_prior operations.""" + space = BasicSpace() + + # Complex chain of operations + final_space = ( + space.add(Float(min_value=50.0, max_value=100.0), "new_param") + .remove("y") + .add_prior("x", prior=0.25, prior_confidence=ConfidenceLevel.HIGH) + .add_prior("new_param", prior=75.0, prior_confidence=ConfidenceLevel.MEDIUM) + .add(Integer(min_value=0, max_value=10), "another_param") + ) + + attrs = final_space.get_attrs() + + # Check structure + assert "x" in attrs + assert "y" not in attrs # Removed + assert "z" in attrs + assert "new_param" in attrs + assert "another_param" in attrs + + # Check priors + assert attrs["x"].has_prior + assert attrs["x"].prior == 0.25 + assert attrs["new_param"].has_prior + assert attrs["new_param"].prior == 75.0 + assert not attrs["z"].has_prior + assert not attrs["another_param"].has_prior + + +def test_immutability(): + """Test that all operations return new instances and don't modify originals.""" + original_space = BasicSpace() + original_attrs = original_space.get_attrs() + + # Perform various operations + space1 = original_space.add(Float(min_value=0.0, max_value=1.0), "temp") + space2 = original_space.remove("x") + space3 = original_space.add_prior("y", prior=5, prior_confidence=ConfidenceLevel.HIGH) + + # Original should be unchanged + assert original_space.get_attrs() == original_attrs + assert not original_space.get_attrs()["y"].has_prior + + # Each operation should create different instances + assert space1 is not original_space + assert space2 is not original_space + assert space3 is not original_space + assert space1 is not space2 + assert space2 is not space3 + + +def test_fidelity_operations(): + """Test operations with fidelity parameters.""" + + class FidelitySpace(PipelineSpace): + x = Float(min_value=0.0, max_value=1.0) + epochs = Fidelity(Integer(min_value=1, max_value=100)) + + space = FidelitySpace() + + # Add another parameter (non-fidelity since add doesn't support Fidelity directly) + new_param = Integer(min_value=1, max_value=50) + space = space.add(new_param, "batch_size") + + # Check that original fidelity is preserved + fidelity_attrs = space.fidelity_attrs + assert "epochs" in fidelity_attrs + assert len(fidelity_attrs) == 1 + + # Remove the fidelity parameter + space = space.remove("epochs") + fidelity_attrs = space.fidelity_attrs + assert "epochs" not in fidelity_attrs + assert len(fidelity_attrs) == 0 + + # Regular parameters should still be there + regular_attrs = space.get_attrs() + assert "x" in regular_attrs + assert "batch_size" in regular_attrs + + +def test_space_string_representation(): + """Test that string representation works after operations.""" + space = BasicSpace() + + # Perform operations + modified_space = ( + space.add(Float(min_value=10.0, max_value=20.0), "added_param") + .remove("y") + .add_prior("x", prior=0.8, prior_confidence=ConfidenceLevel.LOW) + ) + + # Should be able to get string representation without error + str_repr = str(modified_space) + assert "PipelineSpace" in str_repr + assert "added_param" in str_repr + assert "y" not in str_repr # Should be removed diff --git a/tests/test_neps_space/test_search_space__fidelity.py b/tests/test_neps_space/test_search_space__fidelity.py new file mode 100644 index 000000000..820e54191 --- /dev/null +++ b/tests/test_neps_space/test_search_space__fidelity.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import re + +import pytest + +import neps.space.neps_spaces.sampling +from neps.space.neps_spaces import neps_space +from neps.space.neps_spaces.parameters import ( + ConfidenceLevel, + Fidelity, + Float, + Integer, + PipelineSpace, +) + + +class DemoHyperparametersWithFidelitySpace(PipelineSpace): + constant1: int = 42 + float1 = Float( + min_value=0, + max_value=1, + prior=0.1, + prior_confidence=ConfidenceLevel.MEDIUM, + ) + fidelity_integer1 = Fidelity( + domain=Integer( + min_value=1, + max_value=1000, + ), + ) + + +def test_fidelity_creation_raises_when_domain_has_prior(): + # Creating a fidelity object with a domain that has a prior should not be possible. + with pytest.raises( + ValueError, + match=re.escape("The domain of a Fidelity can not have priors, has prior: 10"), + ): + Fidelity( + domain=Integer( + min_value=1, + max_value=1000, + prior=10, + prior_confidence=ConfidenceLevel.MEDIUM, + ), + ) + + +def test_fidelity_resolution_raises_when_resolved_with_no_environment_value(): + pipeline = DemoHyperparametersWithFidelitySpace() + + # Resolve a pipeline which contains a fidelity with an empty environment. + with pytest.raises( + ValueError, + match=re.escape( + "No value is available in the environment for fidelity 'fidelity_integer1'.", + ), + ): + neps_space.resolve(pipeline=pipeline) + + +def test_fidelity_resolution_raises_when_resolved_with_invalid_value(): + pipeline = DemoHyperparametersWithFidelitySpace() + + # Resolve a pipeline which contains a fidelity, + # with an environment value for it, that is out of the allowed range. + with pytest.raises( + ValueError, + match=re.escape( + "Value for fidelity with name 'fidelity_integer1' is outside its allowed" + " range [1, 1000]. Received: -10." + ), + ): + neps_space.resolve( + pipeline=pipeline, + environment_values={"fidelity_integer1": -10}, + ) + + +def test_fidelity_resolution_works(): + pipeline = DemoHyperparametersWithFidelitySpace() + + # Resolve a pipeline which contains a fidelity, + # with a valid value for it in the environment. + resolved_pipeline, _ = neps_space.resolve( + pipeline=pipeline, + environment_values={"fidelity_integer1": 10}, + ) + + assert resolved_pipeline.constant1 == 42 + assert ( + 0.0 <= float(str(resolved_pipeline.float1)) <= 1.0 + ) # 0.0 <= resolved_pipeline.float1 <= 1.0 also works, but gives a type warning + assert resolved_pipeline.fidelity_integer1 == 10 + + +def test_fidelity_resolution_with_context_works(): + pipeline = DemoHyperparametersWithFidelitySpace() + + samplings_to_make = { + "Resolvable.float1::float__0_1_False": 0.5, + } + environment_values = { + "fidelity_integer1": 10, + } + + # Resolve a pipeline which contains a fidelity, + # with a valid value for it in the environment. + resolved_pipeline, resolution_context = neps_space.resolve( + pipeline=pipeline, + domain_sampler=neps.space.neps_spaces.sampling.OnlyPredefinedValuesSampler( + predefined_samplings=samplings_to_make, + ), + environment_values=environment_values, + ) + + assert resolved_pipeline.constant1 == 42 + assert resolved_pipeline.float1 == 0.5 + assert resolved_pipeline.fidelity_integer1 == 10 + + assert resolution_context.samplings_made == samplings_to_make + assert resolution_context.environment_values == environment_values diff --git a/tests/test_neps_space/test_search_space__grammar_like.py b/tests/test_neps_space/test_search_space__grammar_like.py new file mode 100644 index 000000000..df92b6eec --- /dev/null +++ b/tests/test_neps_space/test_search_space__grammar_like.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +import pytest + +import neps.space.neps_spaces.sampling +from neps.space.neps_spaces import config_string, neps_space +from neps.space.neps_spaces.parameters import ( + Categorical, + Operation, + PipelineSpace, + Resampled, +) + + +class GrammarLike(PipelineSpace): + _id = Operation(operator="Identity") + _three = Operation(operator="Conv2D-3") + _one = Operation(operator="Conv2D-1") + _reluconvbn = Operation(operator="ReLUConvBN") + + _O = Categorical(choices=(_three, _one, _id)) + + _C0 = Operation( + operator="Sequential", + args=(Resampled(_O),), + ) + _C1 = Operation( + operator="Sequential", + args=( + Resampled(_O), + Resampled("S"), + _reluconvbn, + ), + ) + _C2 = Operation( + operator="Sequential", + args=( + Resampled(_O), + Resampled("S"), + ), + ) + _C3 = Operation( + operator="Sequential", + args=(Resampled("S"),), + ) + _C = Categorical( + choices=( + Resampled(_C0), + Resampled(_C1), + Resampled(_C2), + Resampled(_C3), + ), + ) + + _S0 = Operation( + operator="Sequential", + args=(Resampled(_C),), + ) + _S1 = Operation( + operator="Sequential", + args=(_reluconvbn,), + ) + _S2 = Operation( + operator="Sequential", + args=(Resampled("S"),), + ) + _S3 = Operation( + operator="Sequential", + args=( + Resampled("S"), + Resampled(_C), + ), + ) + _S4 = Operation( + operator="Sequential", + args=( + Resampled(_O), + Resampled(_O), + Resampled(_O), + ), + ) + _S5 = Operation( + operator="Sequential", + args=( + Resampled("S"), + Resampled("S"), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + ), + ) + S = Categorical( + choices=( + Resampled(_S0), + Resampled(_S1), + Resampled(_S2), + Resampled(_S3), + Resampled(_S4), + Resampled(_S5), + ), + ) + + +class GrammarLikeAlt(PipelineSpace): + _id = Operation(operator="Identity") + _three = Operation(operator="Conv2D-3") + _one = Operation(operator="Conv2D-1") + _reluconvbn = Operation(operator="ReLUConvBN") + + _O = Categorical(choices=(_three, _one, _id)) + + _C_ARGS = Categorical( + choices=( + (Resampled(_O),), + ( + Resampled(_O), + Resampled("S"), + _reluconvbn, + ), + ( + Resampled(_O), + Resampled("S"), + ), + (Resampled("S"),), + ), + ) + _C = Operation( + operator="Sequential", + args=Resampled(_C_ARGS), + ) + + _S_ARGS = Categorical( + choices=( + (Resampled(_C),), + (_reluconvbn,), + (Resampled("S"),), + ( + Resampled("S"), + Resampled(_C), + ), + ( + Resampled(_O), + Resampled(_O), + Resampled(_O), + ), + ( + Resampled("S"), + Resampled("S"), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + Resampled(_O), + ), + ), + ) + S = Operation( + operator="Sequential", + args=Resampled(_S_ARGS), + ) + + +@pytest.mark.repeat(500) +def test_resolve(): + pipeline = GrammarLike() + + try: + resolved_pipeline, _ = neps_space.resolve(pipeline) + except RecursionError: + pytest.xfail("XFAIL due to too much recursion.") + + s = resolved_pipeline.S + s_config_string = neps_space.convert_operation_to_string(s) + assert s_config_string + pretty_config = config_string.ConfigString(s_config_string).pretty_format() + assert pretty_config + + +@pytest.mark.repeat(500) +def test_resolve_alt(): + pipeline = GrammarLikeAlt() + + try: + resolved_pipeline, _ = neps_space.resolve(pipeline) + except RecursionError: + pytest.xfail("XFAIL due to too much recursion.") + + s = resolved_pipeline.S + s_config_string = neps_space.convert_operation_to_string(s) + assert s_config_string + pretty_config = config_string.ConfigString(s_config_string).pretty_format() + assert pretty_config + + +def test_resolve_context(): + samplings_to_make = { + "Resolvable.S::categorical__6": 5, + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__6": ( + 3 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__6": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical::categorical__4": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical::categorical__6": ( + 5 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__6": ( + 0 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__4": ( + 3 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__6": ( + 4 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[2].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical::categorical__6": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[2].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[3].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[4].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[5].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[6].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[7].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[1].resampled_categorical::categorical__6": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__6": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__6": ( + 0 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__4": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[1].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[1].resampled_categorical::categorical__6": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[2].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[3].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[4].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[5].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[6].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.sampled_value.resampled_operation.args.sequence[7].resampled_categorical::categorical__3": ( + 1 + ), + } + expected_s_config_string = ( + "(Sequential (Sequential (Sequential (ReLUConvBN)) (Sequential (Conv2D-3)" + " (Sequential (Sequential (Sequential (Sequential (Identity) (Conv2D-3)" + " (Identity)))) (Sequential (ReLUConvBN)) (Conv2D-3) (Identity) (Conv2D-1)" + " (Conv2D-3) (Conv2D-1) (Identity)) (ReLUConvBN))) (Sequential (Sequential" + " (Sequential (Sequential (Identity) (Sequential (ReLUConvBN)))))) (Conv2D-1)" + " (Conv2D-1) (Identity) (Identity) (Conv2D-1) (Conv2D-1))" + ) + + pipeline = GrammarLike() + + resolved_pipeline, resolution_context = neps_space.resolve( + pipeline, + domain_sampler=neps.space.neps_spaces.sampling.OnlyPredefinedValuesSampler( + predefined_samplings=samplings_to_make, + ), + ) + sampled_values = resolution_context.samplings_made + + assert resolved_pipeline is not None + assert sampled_values is not None + assert sampled_values is not samplings_to_make + assert sampled_values == samplings_to_make + assert list(sampled_values.items()) == list(samplings_to_make.items()) + + # we should have made exactly those samplings + assert sampled_values == samplings_to_make + + s = resolved_pipeline.S + s_config_string = neps_space.convert_operation_to_string(s) + assert s_config_string + assert s_config_string == expected_s_config_string + + +def test_resolve_context_alt(): + samplings_to_make = { + "Resolvable.S.args.resampled_categorical::categorical__6": 3, + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 5 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__4": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__6": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__6": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[2].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[3].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[4].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[5].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[6].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[7].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__4": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__6": ( + 5 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 5 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 3 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__4": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__6": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[2].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[3].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[4].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[5].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[6].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[7].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__6": ( + 5 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__6": ( + 3 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__6": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical::categorical__4": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical::categorical__4": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[0].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[2].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[3].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[4].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[5].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[6].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[7].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[2].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[3].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[4].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[5].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[6].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.S.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[1].resampled_operation.args.resampled_categorical.sampled_value.sequence[7].resampled_categorical::categorical__3": ( + 0 + ), + } + expected_s_config_string = ( + "(Sequential (Sequential (Sequential (Sequential (Sequential " + "(Sequential (Conv2D-3) (Sequential (ReLUConvBN)))) (Sequential " + "(ReLUConvBN)) (Identity) (Conv2D-3) (Conv2D-3) (Conv2D-1) (Conv2D-3) " + "(Identity)))) (Sequential (Conv2D-3) (Sequential (Sequential " + "(Sequential (Sequential (Sequential (ReLUConvBN)) (Sequential " + "(Conv2D-1))) (Sequential (Sequential (ReLUConvBN))) (Conv2D-1) " + "(Conv2D-1) (Identity) (Conv2D-1) (Conv2D-3) (Conv2D-3))) " + "(Sequential (Sequential (ReLUConvBN)) (Sequential (Sequential " + "(Sequential (Identity))) (Sequential (Conv2D-3))) (Conv2D-1) " + "(Identity) (Conv2D-1) (Conv2D-1) (Conv2D-1) (Identity)) (Identity) " + "(Identity) (Identity) (Conv2D-1) (Conv2D-1) (Conv2D-3)) (ReLUConvBN)))" + ) + + pipeline = GrammarLikeAlt() + + resolved_pipeline, resolution_context = neps_space.resolve( + pipeline, + domain_sampler=neps.space.neps_spaces.sampling.OnlyPredefinedValuesSampler( + predefined_samplings=samplings_to_make, + ), + ) + sampled_values = resolution_context.samplings_made + + assert resolved_pipeline is not None + assert sampled_values is not None + assert sampled_values is not samplings_to_make + assert sampled_values == samplings_to_make + assert list(sampled_values.items()) == list(samplings_to_make.items()) + + # we should have made exactly those samplings + assert sampled_values == samplings_to_make + + s = resolved_pipeline.S + s_config_string = neps_space.convert_operation_to_string(s) + assert s_config_string + assert s_config_string == expected_s_config_string diff --git a/tests/test_neps_space/test_search_space__hnas_like.py b/tests/test_neps_space/test_search_space__hnas_like.py new file mode 100644 index 000000000..9b323268b --- /dev/null +++ b/tests/test_neps_space/test_search_space__hnas_like.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import pytest + +import neps.space.neps_spaces.sampling +from neps.space.neps_spaces import config_string, neps_space +from neps.space.neps_spaces.parameters import ( + Categorical, + Float, + Operation, + PipelineSpace, + Resampled, +) + + +class HNASLikePipeline(PipelineSpace): + """Based on the `hierarchical+shared` variant (cell block is shared everywhere). + Across _CONVBLOCK items, _ACT and _CONV also shared. Only the _NORM changes. + + Additionally, this variant now has a PReLU operation with a float hyperparameter (init). + The same value of that hyperparameter would is used everywhere a _PRELU is used. + """ + + # ------------------------------------------------------ + # Adding `PReLU` with a float hyperparameter `init` + # Note that the sampled `_prelu_init_value` will be shared across all `_PRELU` uses, + # since no `Resampled` was requested for it + _prelu_init_value = Float(min_value=0.1, max_value=0.9) + _PRELU = Operation( + operator="ACT prelu", + kwargs={"init": _prelu_init_value}, + ) + # ------------------------------------------------------ + + # Added `_PRELU` to the possible `_ACT` choices + _ACT = Categorical( + choices=( + Operation(operator="ACT relu"), + Operation(operator="ACT hardswish"), + Operation(operator="ACT mish"), + _PRELU, + ), + ) + _CONV = Categorical( + choices=( + Operation(operator="CONV conv1x1"), + Operation(operator="CONV conv3x3"), + Operation(operator="CONV dconv3x3"), + ), + ) + _NORM = Categorical( + choices=( + Operation(operator="NORM batch"), + Operation(operator="NORM instance"), + Operation(operator="NORM layer"), + ), + ) + + _CONVBLOCK = Operation( + operator="CONVBLOCK Sequential3", + args=( + _ACT, + _CONV, + Resampled(_NORM), + ), + ) + _CONVBLOCK_FULL = Operation( + operator="OPS Sequential1", + args=(Resampled(_CONVBLOCK),), + ) + _OP = Categorical( + choices=( + Operation(operator="OPS zero"), + Operation(operator="OPS id"), + Operation(operator="OPS avg_pool"), + Resampled(_CONVBLOCK_FULL), + ), + ) + + CL = Operation( + operator="CELL Cell", + args=( + Resampled(_OP), + Resampled(_OP), + Resampled(_OP), + Resampled(_OP), + Resampled(_OP), + Resampled(_OP), + ), + ) + + _C = Categorical( + choices=( + Operation(operator="C Sequential2", args=(CL, CL)), + Operation(operator="C Sequential3", args=(CL, CL, CL)), + Operation(operator="C Residual2", args=(CL, CL, CL)), + ), + ) + + _RESBLOCK = Operation(operator="resBlock") + _DOWN = Categorical( + choices=( + Operation(operator="DOWN Sequential2", args=(CL, _RESBLOCK)), + Operation(operator="DOWN Sequential3", args=(CL, CL, _RESBLOCK)), + Operation(operator="DOWN Residual2", args=(CL, _RESBLOCK, _RESBLOCK)), + ), + ) + + _D0 = Categorical( + choices=( + Operation( + operator="D0 Sequential3", + args=( + Resampled(_C), + Resampled(_C), + CL, + ), + ), + Operation( + operator="D0 Sequential4", + args=( + Resampled(_C), + Resampled(_C), + Resampled(_C), + CL, + ), + ), + Operation( + operator="D0 Residual3", + args=( + Resampled(_C), + Resampled(_C), + CL, + CL, + ), + ), + ), + ) + _D1 = Categorical( + choices=( + Operation( + operator="D1 Sequential3", + args=( + Resampled(_C), + Resampled(_C), + Resampled(_DOWN), + ), + ), + Operation( + operator="D1 Sequential4", + args=( + Resampled(_C), + Resampled(_C), + Resampled(_C), + Resampled(_DOWN), + ), + ), + Operation( + operator="D1 Residual3", + args=( + Resampled(_C), + Resampled(_C), + Resampled(_DOWN), + Resampled(_DOWN), + ), + ), + ), + ) + + _D2 = Categorical( + choices=( + Operation( + operator="D2 Sequential3", + args=( + Resampled(_D1), + Resampled(_D1), + Resampled(_D0), + ), + ), + Operation( + operator="D2 Sequential3", + args=( + Resampled(_D0), + Resampled(_D1), + Resampled(_D1), + ), + ), + Operation( + operator="D2 Sequential4", + args=( + Resampled(_D1), + Resampled(_D1), + Resampled(_D0), + Resampled(_D0), + ), + ), + ), + ) + + ARCH: Operation = _D2 + + +@pytest.mark.repeat(500) +def test_hnas_like(): + pipeline = HNASLikePipeline() + + resolved_pipeline, resolution_context = neps_space.resolve(pipeline) + assert resolved_pipeline is not None + assert resolution_context.samplings_made is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == ("CL", "ARCH") + + +@pytest.mark.repeat(500) +def test_hnas_like_string(): + pipeline = HNASLikePipeline() + + resolved_pipeline, _ = neps_space.resolve(pipeline) + + arch = resolved_pipeline.ARCH + arch_config_string = neps_space.convert_operation_to_string(arch) + assert arch_config_string + pretty_config = config_string.ConfigString(arch_config_string).pretty_format() + assert pretty_config + + cl = resolved_pipeline.CL + cl_config_string = neps_space.convert_operation_to_string(cl) + assert cl_config_string + pretty_config = config_string.ConfigString(cl_config_string).pretty_format() + assert pretty_config + + +def test_hnas_like_context(): + samplings_to_make = { + "Resolvable.CL.args.sequence[0].resampled_categorical::categorical__4": 3, + "Resolvable.CL.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_operation.args.sequence[0]::categorical__4": ( + 0 + ), + "Resolvable.CL.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_operation.args.sequence[1]::categorical__3": ( + 2 + ), + "Resolvable.CL.args.sequence[0].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_operation.args.sequence[2].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.CL.args.sequence[1].resampled_categorical::categorical__4": 0, + "Resolvable.CL.args.sequence[2].resampled_categorical::categorical__4": 1, + "Resolvable.CL.args.sequence[3].resampled_categorical::categorical__4": 2, + "Resolvable.CL.args.sequence[4].resampled_categorical::categorical__4": 3, + "Resolvable.CL.args.sequence[4].resampled_categorical.sampled_value.resampled_operation.args.sequence[0].resampled_operation.args.sequence[2].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.CL.args.sequence[5].resampled_categorical::categorical__4": 0, + "Resolvable.ARCH::categorical__3": 1, + "Resolvable.ARCH.sampled_value.args.sequence[0].resampled_categorical::categorical__3": 2, + "Resolvable.ARCH.sampled_value.args.sequence[0].resampled_categorical.sampled_value.args.sequence[0].resampled_categorical::categorical__3": ( + 2 + ), + "Resolvable.ARCH.sampled_value.args.sequence[0].resampled_categorical.sampled_value.args.sequence[1].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.ARCH.sampled_value.args.sequence[1].resampled_categorical::categorical__3": 2, + "Resolvable.ARCH.sampled_value.args.sequence[1].resampled_categorical.sampled_value.args.sequence[0].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.ARCH.sampled_value.args.sequence[1].resampled_categorical.sampled_value.args.sequence[1].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.ARCH.sampled_value.args.sequence[1].resampled_categorical.sampled_value.args.sequence[2].resampled_categorical::categorical__3": ( + 0 + ), + "Resolvable.ARCH.sampled_value.args.sequence[1].resampled_categorical.sampled_value.args.sequence[3].resampled_categorical::categorical__3": ( + 1 + ), + "Resolvable.ARCH.sampled_value.args.sequence[2].resampled_categorical::categorical__3": 2, + } + + expected_cl_config_string = ( + "(CELL Cell (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3)" + " (NORM batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK" + " Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer))) (OPS zero))" + ) + expected_arch_config_string = ( + "(D2 Sequential3 (D0 Residual3 (C Residual2 (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero)) (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero)) (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero))) (C Sequential2 (CELL Cell (OPS" + " Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch)))" + " (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT" + " relu) (CONV dconv3x3) (NORM layer))) (OPS zero)) (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero))) (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero)) (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero))) (D1 Residual3 (C Sequential2 (CELL" + " Cell (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM" + " batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK" + " Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer))) (OPS zero)) (CELL Cell" + " (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM" + " batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK" + " Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer))) (OPS zero))) (C" + " Sequential2 (CELL Cell (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV" + " dconv3x3) (NORM batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer))) (OPS zero))" + " (CELL Cell (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3)" + " (NORM batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK" + " Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer))) (OPS zero))) (DOWN" + " Sequential2 (CELL Cell (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV" + " dconv3x3) (NORM batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer))) (OPS zero))" + " resBlock) (DOWN Sequential3 (CELL Cell (OPS Sequential1 (CONVBLOCK Sequential3" + " (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero) (OPS id) (OPS avg_pool)" + " (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM" + " layer))) (OPS zero)) (CELL Cell (OPS Sequential1 (CONVBLOCK Sequential3 (ACT" + " relu) (CONV dconv3x3) (NORM batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS" + " Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer)))" + " (OPS zero)) resBlock)) (D1 Residual3 (C Sequential2 (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero)) (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero))) (C Sequential2 (CELL Cell (OPS" + " Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch)))" + " (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT" + " relu) (CONV dconv3x3) (NORM layer))) (OPS zero)) (CELL Cell (OPS Sequential1" + " (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch))) (OPS zero)" + " (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu)" + " (CONV dconv3x3) (NORM layer))) (OPS zero))) (DOWN Sequential2 (CELL Cell (OPS" + " Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM batch)))" + " (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK Sequential3 (ACT" + " relu) (CONV dconv3x3) (NORM layer))) (OPS zero)) resBlock) (DOWN Sequential3" + " (CELL Cell (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3)" + " (NORM batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK" + " Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer))) (OPS zero)) (CELL Cell" + " (OPS Sequential1 (CONVBLOCK Sequential3 (ACT relu) (CONV dconv3x3) (NORM" + " batch))) (OPS zero) (OPS id) (OPS avg_pool) (OPS Sequential1 (CONVBLOCK" + " Sequential3 (ACT relu) (CONV dconv3x3) (NORM layer))) (OPS zero)) resBlock)))" + ) + + pipeline = HNASLikePipeline() + + resolved_pipeline, resolution_context = neps_space.resolve( + pipeline=pipeline, + domain_sampler=neps.space.neps_spaces.sampling.OnlyPredefinedValuesSampler( + predefined_samplings=samplings_to_make, + ), + ) + sampled_values = resolution_context.samplings_made + + assert resolved_pipeline is not None + assert sampled_values is not None + assert sampled_values is not samplings_to_make + assert sampled_values == samplings_to_make + assert list(sampled_values.items()) == list(samplings_to_make.items()) + + # we should have made exactly those samplings + assert sampled_values == samplings_to_make + + cl = resolved_pipeline.CL + cl_config_string = neps_space.convert_operation_to_string(cl) + assert cl_config_string + assert cl_config_string == expected_cl_config_string + assert "NORM batch" in cl_config_string + assert "NORM layer" in cl_config_string + + arch = resolved_pipeline.ARCH + arch_config_string = neps_space.convert_operation_to_string(arch) + assert arch_config_string + assert arch_config_string == expected_arch_config_string + assert cl_config_string in arch_config_string diff --git a/tests/test_neps_space/test_search_space__nos_like.py b/tests/test_neps_space/test_search_space__nos_like.py new file mode 100644 index 000000000..4d39c9d19 --- /dev/null +++ b/tests/test_neps_space/test_search_space__nos_like.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import pytest + +from neps.space.neps_spaces import config_string, neps_space +from neps.space.neps_spaces.parameters import ( + Categorical, + Integer, + Operation, + PipelineSpace, + Resampled, +) + + +class NosBench(PipelineSpace): + _UNARY_FUN = Categorical( + choices=( + Operation(operator="Square"), + Operation(operator="Exp"), + Operation(operator="Log"), + ) + ) + + _BINARY_FUN = Categorical( + choices=( + Operation(operator="Add"), + Operation(operator="Sub"), + Operation(operator="Mul"), + ) + ) + + _TERNARY_FUN = Categorical( + choices=( + Operation(operator="Interpolate"), + Operation(operator="Bias_Correct"), + ) + ) + + _PARAMS = Categorical( + choices=( + Operation(operator="Params"), + Operation(operator="Gradient"), + Operation(operator="Opt_Step"), + ) + ) + _CONST = Integer(3, 8) + _VAR = Integer(9, 19) + + _POINTER = Categorical( + choices=( + Resampled(_PARAMS), + Resampled(_CONST), + Resampled(_VAR), + ), + ) + + _UNARY = Operation( + operator="Unary", + args=( + Resampled(_UNARY_FUN), + Resampled(_POINTER), + ), + ) + + _BINARY = Operation( + operator="Binary", + args=( + Resampled(_BINARY_FUN), + Resampled(_POINTER), + Resampled(_POINTER), + ), + ) + + _TERNARY = Operation( + operator="Ternary", + args=( + Resampled(_TERNARY_FUN), + Resampled(_POINTER), + Resampled(_POINTER), + Resampled(_POINTER), + ), + ) + + _F_ARGS = Categorical( + choices=( + Resampled(_UNARY), + Resampled(_BINARY), + Resampled(_TERNARY), + ), + ) + + _F = Operation( + operator="Function", + args=(Resampled(_F_ARGS),), + kwargs={"var": Resampled(_VAR)}, + ) + + _L_ARGS = Categorical( + choices=( + (Resampled(_F),), + ( + Resampled(_F), + Resampled("_L"), + ), + ), + ) + + _L = Operation( + operator="Line_operator", + args=Resampled(_L_ARGS), + ) + + P = Operation( + operator="Program", + args=(Resampled(_L),), + ) + + +@pytest.mark.repeat(500) +def test_resolve(): + pipeline = NosBench() + + try: + resolved_pipeline, _ = neps_space.resolve(pipeline) + except RecursionError: + pytest.xfail("XFAIL due to too much recursion.") + raise + + p = resolved_pipeline.P + p_config_string = neps_space.convert_operation_to_string(p) + assert p_config_string + pretty_config = config_string.ConfigString(p_config_string).pretty_format() + assert pretty_config diff --git a/tests/test_neps_space/test_search_space__recursion.py b/tests/test_neps_space/test_search_space__recursion.py new file mode 100644 index 000000000..f2ca9d60d --- /dev/null +++ b/tests/test_neps_space/test_search_space__recursion.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence + +from neps.space.neps_spaces import neps_space +from neps.space.neps_spaces.parameters import ( + Categorical, + Float, + Operation, + PipelineSpace, + Resampled, +) + + +class Model: + """An inner function that sums the values and multiplies the result by a factor. + This class can be recursively used in a search space to create nested models. + """ + + def __init__( + self, + inner_function: Callable[[Sequence[float]], float], + factor: float, + ): + """Initialize the model with an inner function and a factor.""" + self.inner_function = inner_function + self.factor = factor + + def __call__(self, values: Sequence[float]) -> float: + return self.factor * self.inner_function(values) + + +class Sum: + """A simple inner function that sums the values.""" + + def __call__(self, values: Sequence[float]) -> float: + return sum(values) + + +class DemoRecursiveOperationSpace(PipelineSpace): + # The way to sample `factor` values + _factor = Float(min_value=0, max_value=1) + + # Sum + _sum = Operation(operator=Sum) + + # Model + # Can recursively request itself as an arg. + # Will be equivalent to something like one of + # `Model(Sum(), factor=0.1)` + # `Model(Model(Sum(), factor=0.1), factor=0.1)` + # `Model(Model(Model(Sum(), factor=0.1), factor=0.1), factor=0.1)` + # ... + # If we want the `factor` values to be different, + # we just request a resample for them + _inner_function = Categorical( + choices=(_sum, Resampled("model")), + ) + model = Operation( + operator=Model, + args=(Resampled(_inner_function),), + kwargs={"factor": _factor}, + ) + + +def test_recursion(): + pipeline = DemoRecursiveOperationSpace() + + # Across `n` iterations we collect the number of seen inner `Model` counts. + # We expect to see at least `k` cases for that number + expected_minimal_number_of_recursions = 3 + seen_inner_model_counts = [] + + for _ in range(200): + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + model = resolved_pipeline.model + assert model.operator is Model + + inner_function = model.args[0] + seen_factors = [model.kwargs["factor"]] + seen_inner_model_count = 0 + + # Loop into the inner operators until we have no more nested `Model` args + while inner_function.operator is Model: + seen_factors.append(inner_function.kwargs["factor"]) + seen_inner_model_count += 1 + inner_function = inner_function.args[0] + + # At this point we should have gone deep enough to have the terminal `Sum` + assert inner_function.operator is Sum + + # We should have seen as many factors as inner models + 1 for the outer one + assert len(seen_factors) == seen_inner_model_count + 1 + + # All the factors should be the same value (shared) + assert len(set(seen_factors)) == 1 + assert isinstance(seen_factors[0], float) + + # Add the number of seen `Model` operator in the loop + seen_inner_model_counts.append(seen_inner_model_count) + + assert len(set(seen_inner_model_counts)) >= expected_minimal_number_of_recursions + + +# TODO: test context with recursion (`samplings_to_make`) diff --git a/tests/test_neps_space/test_search_space__resampled.py b/tests/test_neps_space/test_search_space__resampled.py new file mode 100644 index 000000000..fc4fe3450 --- /dev/null +++ b/tests/test_neps_space/test_search_space__resampled.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import pytest + +from neps.space.neps_spaces import neps_space +from neps.space.neps_spaces.parameters import ( + Categorical, + ConfidenceLevel, + Float, + Integer, + Operation, + PipelineSpace, + Resampled, +) + + +class ActPipelineSimpleFloat(PipelineSpace): + prelu_init_value = Float( + min_value=0, + max_value=1000000, + log=False, + prior=0.25, + prior_confidence=ConfidenceLevel.LOW, + ) + + prelu_shared1 = Operation( + operator="prelu", + kwargs={"init": prelu_init_value}, + ) + prelu_shared2 = Operation( + operator="prelu", + kwargs={"init": prelu_init_value}, + ) + + prelu_own_clone1 = Operation( + operator="prelu", + kwargs={"init": Resampled(prelu_init_value)}, + ) + prelu_own_clone2 = Operation( + operator="prelu", + kwargs={"init": Resampled(prelu_init_value)}, + ) + + _prelu_init_resampled = Resampled(prelu_init_value) + prelu_common_clone1 = Operation( + operator="prelu", + kwargs={"init": _prelu_init_resampled}, + ) + prelu_common_clone2 = Operation( + operator="prelu", + kwargs={"init": _prelu_init_resampled}, + ) + + +class ActPipelineComplexInteger(PipelineSpace): + prelu_init_value = Integer(min_value=0, max_value=1000000) + + prelu_shared1 = Operation( + operator="prelu", + kwargs={"init": prelu_init_value}, + ) + prelu_shared2 = Operation( + operator="prelu", + kwargs={"init": prelu_init_value}, + ) + + prelu_own_clone1 = Operation( + operator="prelu", + kwargs={"init": Resampled(prelu_init_value)}, + ) + prelu_own_clone2 = Operation( + operator="prelu", + kwargs={"init": Resampled(prelu_init_value)}, + ) + + _prelu_init_resampled = Resampled(prelu_init_value) + prelu_common_clone1 = Operation( + operator="prelu", + kwargs={"init": _prelu_init_resampled}, + ) + prelu_common_clone2 = Operation( + operator="prelu", + kwargs={"init": _prelu_init_resampled}, + ) + + act: Operation = Operation( + operator="sequential6", + args=( + prelu_shared1, + prelu_shared2, + prelu_own_clone1, + prelu_own_clone2, + prelu_common_clone1, + prelu_common_clone2, + ), + kwargs={ + "prelu_shared": prelu_shared1, + "prelu_own_clone": prelu_own_clone1, + "prelu_common_clone": prelu_common_clone1, + "resampled_hp_value": Resampled(prelu_init_value), + }, + ) + + +class CellPipelineCategorical(PipelineSpace): + conv_block = Categorical( + choices=( + Operation(operator="conv1"), + Operation(operator="conv2"), + ), + ) + + op1 = Categorical( + choices=( + conv_block, + Operation("op1"), + ), + ) + op2 = Categorical( + choices=( + Resampled(conv_block), + Operation("op2"), + ), + ) + + _resampled_op1 = Resampled(op1) + cell = Operation( + operator="cell", + args=( + op1, + op2, + _resampled_op1, + Resampled(op2), + _resampled_op1, + Resampled(op2), + ), + ) + + +@pytest.mark.repeat(200) +def test_resampled_float(): + pipeline = ActPipelineSimpleFloat() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + assert resolved_pipeline is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == ( + "prelu_init_value", + "prelu_shared1", + "prelu_shared2", + "prelu_own_clone1", + "prelu_own_clone2", + "prelu_common_clone1", + "prelu_common_clone2", + ) + + prelu_init_value = resolved_pipeline.prelu_init_value + prelu_shared1 = resolved_pipeline.prelu_shared1.kwargs["init"] + prelu_shared2 = resolved_pipeline.prelu_shared2.kwargs["init"] + resampled_values = ( + resolved_pipeline.prelu_own_clone1.kwargs["init"], + resolved_pipeline.prelu_own_clone2.kwargs["init"], + resolved_pipeline.prelu_common_clone1.kwargs["init"], + resolved_pipeline.prelu_common_clone2.kwargs["init"], + ) + + assert isinstance(prelu_init_value, float) + assert isinstance(prelu_shared1, float) + assert isinstance(prelu_shared2, float) + assert all(isinstance(resampled_value, float) for resampled_value in resampled_values) + + assert prelu_init_value == prelu_shared1 + assert prelu_init_value == prelu_shared2 + + assert len(set(resampled_values)) == len(resampled_values) + assert all( + resampled_value != prelu_init_value for resampled_value in resampled_values + ) + + +@pytest.mark.repeat(200) +def test_resampled_integer(): + pipeline = ActPipelineComplexInteger() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + assert resolved_pipeline is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == ( + "prelu_init_value", + "prelu_shared1", + "prelu_shared2", + "prelu_own_clone1", + "prelu_own_clone2", + "prelu_common_clone1", + "prelu_common_clone2", + "act", + ) + + prelu_init_value = resolved_pipeline.prelu_init_value + prelu_shared1 = resolved_pipeline.prelu_shared1.kwargs["init"] + prelu_shared2 = resolved_pipeline.prelu_shared2.kwargs["init"] + resampled_values = ( + resolved_pipeline.prelu_own_clone1.kwargs["init"], + resolved_pipeline.prelu_own_clone2.kwargs["init"], + resolved_pipeline.prelu_common_clone1.kwargs["init"], + resolved_pipeline.prelu_common_clone2.kwargs["init"], + ) + + assert isinstance(prelu_init_value, int) + assert isinstance(prelu_shared1, int) + assert isinstance(prelu_shared2, int) + assert all(isinstance(resampled_value, int) for resampled_value in resampled_values) + + assert prelu_init_value == prelu_shared1 + assert prelu_init_value == prelu_shared2 + + assert len(set(resampled_values)) == len(resampled_values) + assert all( + resampled_value != prelu_init_value for resampled_value in resampled_values + ) + + act = resolved_pipeline.act + + act_args = tuple(op.kwargs["init"] for op in act.args) + sampled_values = (prelu_shared1, prelu_shared2, *resampled_values) + assert len(act_args) == len(sampled_values) + for act_arg, sampled_value in zip(act_args, sampled_values, strict=False): + assert act_arg is sampled_value + + act_resampled_prelu_shared = act.kwargs["prelu_shared"].kwargs["init"] + act_resampled_prelu_own_clone = act.kwargs["prelu_own_clone"].kwargs["init"] + act_resampled_prelu_common_clone = act.kwargs["prelu_common_clone"].kwargs["init"] + + assert isinstance(act_resampled_prelu_shared, int) + assert isinstance(act_resampled_prelu_own_clone, int) + assert isinstance(act_resampled_prelu_common_clone, int) + + assert act_resampled_prelu_shared == prelu_init_value + assert act_resampled_prelu_own_clone != prelu_init_value + assert act_resampled_prelu_common_clone != prelu_init_value + assert act_resampled_prelu_own_clone != act_resampled_prelu_common_clone + + act_resampled_hp_value = act.kwargs["resampled_hp_value"] + assert isinstance(act_resampled_hp_value, int) + assert act_resampled_hp_value != prelu_init_value + assert all( + resampled_value != act_resampled_hp_value for resampled_value in resampled_values + ) + + +@pytest.mark.repeat(200) +def test_resampled_categorical(): + pipeline = CellPipelineCategorical() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + assert resolved_pipeline is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == ( + "conv_block", + "op1", + "op2", + "cell", + ) + + conv_block = resolved_pipeline.conv_block + assert conv_block is not pipeline.conv_block + + op1 = resolved_pipeline.op1 + op2 = resolved_pipeline.op2 + assert op1 is not pipeline.op1 + assert op2 is not pipeline.op2 + + assert isinstance(op1, Operation) + assert isinstance(op2, Operation) + + assert (op1 is conv_block) or (op1.operator == "op1") + assert op2.operator in ("conv1", "conv2", "op2") + + cell = resolved_pipeline.cell + assert cell is not pipeline.cell + + cell_args1 = cell.args[0] + cell_args2 = cell.args[1] + + assert cell_args1 is op1 + assert cell_args2 is op2 + # todo: think about what more tests we can have for cell_args[3-6] diff --git a/tests/test_neps_space/test_search_space__reuse_arch_elements.py b/tests/test_neps_space/test_search_space__reuse_arch_elements.py new file mode 100644 index 000000000..c5554236a --- /dev/null +++ b/tests/test_neps_space/test_search_space__reuse_arch_elements.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import pytest + +import neps.space.neps_spaces.sampling +from neps.space.neps_spaces import neps_space +from neps.space.neps_spaces.parameters import ( + Categorical, + ConfidenceLevel, + Float, + Integer, + Operation, + PipelineSpace, +) + + +class ActPipelineSimple(PipelineSpace): + prelu_with_args = Operation( + operator="prelu_with_args", + args=(0.1, 0.2), + ) + prelu_with_kwargs = Operation( + operator="prelu_with_kwargs", + kwargs={"init": 0.1}, + ) + relu = Operation(operator="relu") + + act: Operation = Categorical( + choices=(prelu_with_args, prelu_with_kwargs, relu), + ) + + +class ActPipelineComplex(PipelineSpace): + prelu_init_value: float = Float(min_value=0.1, max_value=0.9) + prelu = Operation( + operator="prelu", + kwargs={"init": prelu_init_value}, + ) + act: Operation = Categorical( + choices=(prelu,), + ) + + +class FixedPipeline(PipelineSpace): + prelu_init_value: float = 0.5 + prelu = Operation( + operator="prelu", + kwargs={"init": prelu_init_value}, + ) + act = prelu + + +_conv_choices_low = ("conv1x1", "conv3x3") +_conv_choices_high = ("conv5x5", "conv9x9") +_conv_choices_prior_confidence_choices = ( + ConfidenceLevel.LOW, + ConfidenceLevel.MEDIUM, + ConfidenceLevel.HIGH, +) + + +class ConvPipeline(PipelineSpace): + conv_choices_prior_index: int = Integer( + min_value=0, + max_value=1, + log=False, + prior=0, + prior_confidence=ConfidenceLevel.LOW, + ) + conv_choices_prior_confidence: ConfidenceLevel = Categorical( + choices=_conv_choices_prior_confidence_choices, + prior=1, + prior_confidence=ConfidenceLevel.LOW, + ) + conv_choices: tuple[str, ...] = Categorical( + choices=(_conv_choices_low, _conv_choices_high), + prior=conv_choices_prior_index, + prior_confidence=conv_choices_prior_confidence, + ) + + _conv1: str = Categorical( + choices=conv_choices, + ) + _conv2: str = Categorical( + choices=conv_choices, + ) + + conv_block: Operation = Categorical( + choices=( + Operation( + operator="sequential3", + args=[_conv1, _conv2, _conv1], + ), + ), + ) + + +class CellPipeline(PipelineSpace): + _act = Operation(operator="relu") + _conv = Operation(operator="conv3x3") + _norm = Operation(operator="batch") + + conv_block = Operation(operator="sequential3", args=(_act, _conv, _norm)) + + op1 = Categorical( + choices=( + conv_block, + Operation(operator="zero"), + Operation(operator="avg_pool"), + ), + ) + op2 = Categorical( + choices=( + conv_block, + Operation(operator="zero"), + Operation(operator="avg_pool"), + ), + ) + + _some_int = 2 + _some_float = Float(min_value=0.5, max_value=0.5) + + cell = Operation( + operator="cell", + args=(op1, op2, op1, op2, op1, op2), + kwargs={"float_hp": _some_float, "int_hp": _some_int}, + ) + + +@pytest.mark.repeat(50) +def test_nested_simple(): + pipeline = ActPipelineSimple() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + assert resolved_pipeline is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == ( + "prelu_with_args", + "prelu_with_kwargs", + "relu", + "act", + ) + + assert resolved_pipeline.prelu_with_kwargs is pipeline.prelu_with_kwargs + assert resolved_pipeline.prelu_with_args is pipeline.prelu_with_args + assert resolved_pipeline.relu is pipeline.relu + + assert resolved_pipeline.act in ( + resolved_pipeline.prelu_with_kwargs, + resolved_pipeline.prelu_with_args, + resolved_pipeline.relu, + ) + + +@pytest.mark.repeat(50) +def test_nested_simple_string(): + possible_cell_config_strings = { + "(relu)", + "(prelu_with_args (0.1) (0.2))", + "(prelu_with_kwargs {'init': 0.1})", + } + + pipeline = ActPipelineSimple() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + act = resolved_pipeline.act + act_config_string = neps_space.convert_operation_to_string(act) + assert act_config_string + assert act_config_string in possible_cell_config_strings + + +@pytest.mark.repeat(50) +def test_nested_complex(): + pipeline = ActPipelineComplex() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + assert resolved_pipeline is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == ( + "prelu_init_value", + "prelu", + "act", + ) + + prelu_init_value = resolved_pipeline.prelu_init_value + assert 0.1 <= prelu_init_value <= 0.9 + + prelu = resolved_pipeline.prelu + assert prelu.operator == "prelu" + assert isinstance(prelu.kwargs["init"], float) + assert prelu.kwargs["init"] is prelu_init_value + assert not prelu.args + + act = resolved_pipeline.act + assert act.operator == "prelu" + assert act is prelu + + +@pytest.mark.repeat(50) +def test_nested_complex_string(): + pipeline = ActPipelineComplex() + + resolved_pipeline, _ = neps_space.resolve(pipeline) + + act = resolved_pipeline.act + act_config_string = neps_space.convert_operation_to_string(act) + assert act_config_string + + # expected to look like: "(prelu {'init': 0.1087727907176638})" + expected_prefix = "(prelu {'init': " + expected_ending = "})" + assert act_config_string.startswith(expected_prefix) + assert act_config_string.endswith(expected_ending) + assert ( + 0.1 + <= float(act_config_string[len(expected_prefix) : -len(expected_ending)]) + <= 0.9 + ) + + +def test_fixed_pipeline(): + pipeline = FixedPipeline() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + assert resolved_pipeline is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == tuple( + pipeline.get_attrs().keys() + ) + + assert resolved_pipeline.prelu_init_value == pipeline.prelu_init_value + assert resolved_pipeline.prelu is pipeline.prelu + assert resolved_pipeline.act is pipeline.act + assert resolved_pipeline is pipeline + + +def test_fixed_pipeline_string(): + pipeline = FixedPipeline() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + act = resolved_pipeline.act + act_config_string = neps_space.convert_operation_to_string(act) + assert act_config_string + assert act_config_string == "(prelu {'init': 0.5})" + + +@pytest.mark.repeat(50) +def test_simple_reuse(): + pipeline = ConvPipeline() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + assert resolved_pipeline is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == ( + "conv_choices_prior_index", + "conv_choices_prior_confidence", + "conv_choices", + "conv_block", + ) + + conv_choices_prior_index = resolved_pipeline.conv_choices_prior_index + assert conv_choices_prior_index in (0, 1) + + conv_choices_prior_confidence = resolved_pipeline.conv_choices_prior_confidence + assert conv_choices_prior_confidence in _conv_choices_prior_confidence_choices + + conv_choices = resolved_pipeline.conv_choices + assert conv_choices in (_conv_choices_low, _conv_choices_high) + + conv_block = resolved_pipeline.conv_block + assert conv_block.operator == "sequential3" + for conv in conv_block.args: + assert conv in conv_choices + assert conv_block.args[0] == conv_block.args[2] + + +@pytest.mark.repeat(50) +def test_simple_reuse_string(): + possible_conv_block_config_strings = { + "(sequential3 (conv1x1) (conv1x1) (conv1x1))", + "(sequential3 (conv1x1) (conv3x3) (conv1x1))", + "(sequential3 (conv3x3) (conv1x1) (conv3x3))", + "(sequential3 (conv3x3) (conv3x3) (conv3x3))", + "(sequential3 (conv5x5) (conv5x5) (conv5x5))", + "(sequential3 (conv5x5) (conv9x9) (conv5x5))", + "(sequential3 (conv9x9) (conv5x5) (conv9x9))", + "(sequential3 (conv9x9) (conv9x9) (conv9x9))", + } + + pipeline = ConvPipeline() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + conv_block = resolved_pipeline.conv_block + conv_block_config_string = neps_space.convert_operation_to_string(conv_block) + assert conv_block_config_string + assert conv_block_config_string in possible_conv_block_config_strings + + +@pytest.mark.repeat(50) +def test_shared_complex(): + pipeline = CellPipeline() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + assert resolved_pipeline is not pipeline + assert resolved_pipeline is not None + assert tuple(resolved_pipeline.get_attrs().keys()) == ( + "conv_block", + "op1", + "op2", + "cell", + ) + + conv_block = resolved_pipeline.conv_block + assert conv_block is pipeline.conv_block + + op1 = resolved_pipeline.op1 + op2 = resolved_pipeline.op2 + assert op1 is not pipeline.op1 + assert op2 is not pipeline.op2 + assert isinstance(op1, Operation) + assert isinstance(op2, Operation) + + if op1 is op2: + assert op1 is conv_block + else: + assert op1.operator in {"zero", "avg_pool", "sequential3"} + assert op2.operator in {"zero", "avg_pool", "sequential3"} + if op1.operator == "sequential3" or op2.operator == "sequential3": + assert op1.operator != op2.operator + + cell = resolved_pipeline.cell + assert cell is not pipeline.cell + assert cell.operator == "cell" + assert cell.args[0] is op1 + assert cell.args[1] is op2 + assert cell.args[2] is op1 + assert cell.args[3] is op2 + assert cell.args[4] is op1 + assert cell.args[5] is op2 + assert len(cell.kwargs) == 2 + assert cell.kwargs["float_hp"] == 0.5 + assert cell.kwargs["int_hp"] == 2 + + +@pytest.mark.repeat(50) +def test_shared_complex_string(): + possible_cell_config_strings = { + ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (avg_pool) (avg_pool) (avg_pool)" + " (avg_pool) (avg_pool) (avg_pool))" + ), + ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (zero) (sequential3 (relu) (conv3x3)" + " (batch)) (zero) (sequential3 (relu) (conv3x3) (batch)) (zero) (sequential3" + " (relu) (conv3x3) (batch)))" + ), + ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (sequential3 (relu) (conv3x3) (batch))" + " (avg_pool) (sequential3 (relu) (conv3x3) (batch)) (avg_pool) (sequential3" + " (relu) (conv3x3) (batch)) (avg_pool))" + ), + "(cell {'float_hp': 0.5, 'int_hp': 2} (zero) (zero) (zero) (zero) (zero) (zero))", + ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (zero) (avg_pool) (zero) (avg_pool)" + " (zero) (avg_pool))" + ), + ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (sequential3 (relu) (conv3x3) (batch))" + " (sequential3 (relu) (conv3x3) (batch)) (sequential3 (relu) (conv3x3)" + " (batch)) (sequential3 (relu) (conv3x3) (batch)) (sequential3 (relu)" + " (conv3x3) (batch)) (sequential3 (relu) (conv3x3) (batch)))" + ), + ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (avg_pool) (zero) (avg_pool) (zero)" + " (avg_pool) (zero))" + ), + ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (sequential3 (relu) (conv3x3) (batch))" + " (zero) (sequential3 (relu) (conv3x3) (batch)) (zero) (sequential3 (relu)" + " (conv3x3) (batch)) (zero))" + ), + ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (avg_pool) (sequential3 (relu)" + " (conv3x3) (batch)) (avg_pool) (sequential3 (relu) (conv3x3) (batch))" + " (avg_pool) (sequential3 (relu) (conv3x3) (batch)))" + ), + } + + pipeline = CellPipeline() + + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + + cell = resolved_pipeline.cell + cell_config_string = neps_space.convert_operation_to_string(cell) + assert cell_config_string + assert cell_config_string in possible_cell_config_strings + + +def test_shared_complex_context(): + # todo: move the context testing part to its own test file. + # This one should only do the reuse tests + + # todo: add a more complex test, where we have hidden Categorical choices. + # E.g. add Resampled along the way + + samplings_to_make = { + "Resolvable.op1::categorical__3": 2, + "Resolvable.op2::categorical__3": 1, + "Resolvable.cell.kwargs.mapping_value{float_hp}::float__0.5_0.5_False": 0.5, + } + + pipeline = CellPipeline() + + resolved_pipeline_first, _resolution_context_first = neps_space.resolve( + pipeline=pipeline, + domain_sampler=neps.space.neps_spaces.sampling.OnlyPredefinedValuesSampler( + predefined_samplings=samplings_to_make, + ), + ) + sampled_values_first = _resolution_context_first.samplings_made + + assert resolved_pipeline_first is not pipeline + assert sampled_values_first is not None + assert sampled_values_first is not samplings_to_make + assert sampled_values_first == samplings_to_make + assert list(sampled_values_first.items()) == list(samplings_to_make.items()) + + resolved_pipeline_second, _resolution_context_second = neps_space.resolve( + pipeline=pipeline, + domain_sampler=neps.space.neps_spaces.sampling.OnlyPredefinedValuesSampler( + predefined_samplings=samplings_to_make, + ), + ) + sampled_values_second = _resolution_context_second.samplings_made + + assert resolved_pipeline_second is not pipeline + assert resolved_pipeline_second is not None + assert sampled_values_second is not samplings_to_make + assert sampled_values_second == samplings_to_make + assert list(sampled_values_second.items()) == list(samplings_to_make.items()) + + # the second resolution should give us a new object + assert resolved_pipeline_second is not resolved_pipeline_first + + expected_config_string: str = ( + "(cell {'float_hp': 0.5, 'int_hp': 2} (avg_pool) (zero) (avg_pool) (zero)" + " (avg_pool) (zero))" + ) + + # however, their final results should be the same thing + assert ( + neps_space.convert_operation_to_string(resolved_pipeline_first.cell) + == expected_config_string + ) + assert ( + neps_space.convert_operation_to_string(resolved_pipeline_second.cell) + == expected_config_string + ) diff --git a/tests/test_neps_space/test_space_conversion_and_compatibility.py b/tests/test_neps_space/test_space_conversion_and_compatibility.py new file mode 100644 index 000000000..360c57374 --- /dev/null +++ b/tests/test_neps_space/test_space_conversion_and_compatibility.py @@ -0,0 +1,495 @@ +"""Tests for space conversion and algorithm compatibility in NePS.""" + +from __future__ import annotations + +import pytest + +import neps +from neps.optimizers import algorithms +from neps.space.neps_spaces.neps_space import ( + check_neps_space_compatibility, + convert_classic_to_neps_search_space, + convert_neps_to_classic_search_space, +) +from neps.space.neps_spaces.parameters import ( + Categorical, + ConfidenceLevel, + Fidelity, + Float, + Integer, + Operation, + PipelineSpace, + Resampled, +) + + +class SimpleHPOSpace(PipelineSpace): + """Simple hyperparameter-only space that can be converted to classic.""" + + x = Float( + min_value=0.0, max_value=1.0, prior=0.5, prior_confidence=ConfidenceLevel.MEDIUM + ) + y = Integer(min_value=1, max_value=10, prior=5, prior_confidence=ConfidenceLevel.HIGH) + z = Categorical( + choices=("a", "b", "c"), prior=1, prior_confidence=ConfidenceLevel.LOW + ) + + +class SimpleHPOWithFidelitySpace(PipelineSpace): + """Simple hyperparameter space with fidelity.""" + + x = Float( + min_value=0.0, max_value=1.0, prior=0.5, prior_confidence=ConfidenceLevel.MEDIUM + ) + y = Integer(min_value=1, max_value=10, prior=5, prior_confidence=ConfidenceLevel.HIGH) + epochs = Fidelity(Integer(min_value=1, max_value=100)) + + +class ComplexNepsSpace(PipelineSpace): + """Complex NePS space that cannot be converted to classic.""" + + # Basic parameters + factor = Float( + min_value=0.1, max_value=2.0, prior=1.0, prior_confidence=ConfidenceLevel.MEDIUM + ) + + # Operation with resampled parameters + operation = Operation( + operator=lambda x, y: x * y, + args=(factor, Resampled(factor)), + ) + + # Categorical with operations as choices + choice = Categorical( + choices=(operation, factor), + prior=0, + prior_confidence=ConfidenceLevel.LOW, + ) + + +# ===== Test space conversion functions ===== + + +def test_convert_classic_to_neps(): + """Test conversion from classic SearchSpace to NePS PipelineSpace.""" + # Create a classic SearchSpace with various parameter types + classic_space = neps.SearchSpace( + { + "float_param": neps.HPOFloat(0.0, 1.0, prior=0.5, prior_confidence="medium"), + "int_param": neps.HPOInteger(1, 10, prior=5, prior_confidence="high"), + "cat_param": neps.HPOCategorical( + ["a", "b", "c"], prior="b", prior_confidence="low" + ), + "fidelity_param": neps.HPOInteger(1, 100, is_fidelity=True), + "constant_param": neps.HPOConstant("constant_value"), + } + ) + + # Convert to NePS space + neps_space = convert_classic_to_neps_search_space(classic_space) + assert isinstance(neps_space, PipelineSpace) + + # Verify attributes are preserved + neps_attrs = neps_space.get_attrs() + assert len(neps_attrs) == 5 + assert all( + name in neps_attrs + for name in [ + "float_param", + "int_param", + "cat_param", + "fidelity_param", + "constant_param", + ] + ) + + # Verify types and properties + assert isinstance(neps_attrs["float_param"], Float) + assert neps_attrs["float_param"].has_prior + assert neps_attrs["float_param"].prior == 0.5 + assert neps_attrs["float_param"].prior_confidence == ConfidenceLevel.MEDIUM + + assert isinstance(neps_attrs["int_param"], Integer) + assert neps_attrs["int_param"].has_prior + assert neps_attrs["int_param"].prior == 5 + assert neps_attrs["int_param"].prior_confidence == ConfidenceLevel.HIGH + + assert isinstance(neps_attrs["cat_param"], Categorical) + assert neps_attrs["cat_param"].has_prior + assert neps_attrs["cat_param"].prior == 1 # Index of "b" in choices + assert neps_attrs["cat_param"].prior_confidence == ConfidenceLevel.LOW + + assert isinstance(neps_attrs["fidelity_param"], Fidelity) + assert isinstance(neps_attrs["fidelity_param"]._domain, Integer) + + # Constant should be preserved as-is + assert neps_attrs["constant_param"] == "constant_value" + + +def test_convert_neps_to_classic_simple(): + """Test conversion from simple NePS PipelineSpace to classic SearchSpace.""" + space = SimpleHPOSpace() + + # Convert to classic space + classic_space = convert_neps_to_classic_search_space(space) + assert classic_space is not None + assert isinstance(classic_space, neps.SearchSpace) + + # Verify attributes are preserved + classic_attrs = classic_space.elements + assert len(classic_attrs) == 3 + assert all(name in classic_attrs for name in ["x", "y", "z"]) + + # Verify types and priors + x_param = classic_attrs["x"] + assert isinstance(x_param, neps.HPOFloat) + assert x_param.lower == 0.0 + assert x_param.upper == 1.0 + assert x_param.prior == 0.5 + assert x_param.prior_confidence == "medium" + + y_param = classic_attrs["y"] + assert isinstance(y_param, neps.HPOInteger) + assert y_param.lower == 1 + assert y_param.upper == 10 + assert y_param.prior == 5 + assert y_param.prior_confidence == "high" + + z_param = classic_attrs["z"] + assert isinstance(z_param, neps.HPOCategorical) + assert set(z_param.choices) == {"a", "b", "c"} # Order might vary + assert z_param.prior == "b" + assert z_param.prior_confidence == "low" + + +def test_convert_neps_to_classic_with_fidelity(): + """Test conversion from NePS PipelineSpace with fidelity to classic SearchSpace.""" + space = SimpleHPOWithFidelitySpace() + + # Convert to classic space + classic_space = convert_neps_to_classic_search_space(space) + assert classic_space is not None + assert isinstance(classic_space, neps.SearchSpace) + + # Verify fidelity parameter + epochs_param = classic_space.elements["epochs"] + assert isinstance(epochs_param, neps.HPOInteger) + assert epochs_param.is_fidelity + assert epochs_param.lower == 1 + assert epochs_param.upper == 100 + + +def test_convert_complex_neps_to_classic_fails(): + """Test that complex NePS spaces cannot be converted to classic.""" + space = ComplexNepsSpace() + + # This space should NOT be convertible to classic + converted = convert_neps_to_classic_search_space(space) + assert converted is None + + +def test_round_trip_conversion(): + """Test that simple spaces can be converted back and forth.""" + # Start with classic space + original_classic = neps.SearchSpace( + { + "x": neps.HPOFloat(0.0, 1.0, prior=0.5, prior_confidence="medium"), + "y": neps.HPOInteger(1, 10, prior=5, prior_confidence="high"), + "z": neps.HPOCategorical(["a", "b", "c"], prior="b", prior_confidence="low"), + } + ) + + # Convert to NePS and back + neps_space = convert_classic_to_neps_search_space(original_classic) + converted_back = convert_neps_to_classic_search_space(neps_space) + + assert converted_back is not None + assert len(converted_back.elements) == len(original_classic.elements) + + # Verify parameters are equivalent + for name in original_classic.elements: + original_param = original_classic.elements[name] + converted_param = converted_back.elements[name] + + assert type(original_param) is type(converted_param) + + # Check bounds for numerical parameters + if isinstance(original_param, neps.HPOFloat | neps.HPOInteger): + assert original_param.lower == converted_param.lower + assert original_param.upper == converted_param.upper + + # Check choices for categorical parameters + if isinstance(original_param, neps.HPOCategorical): + # Sort choices for comparison since order might differ + assert set(original_param.choices) == set(converted_param.choices) + + # Check priors + if hasattr(original_param, "prior") and hasattr(converted_param, "prior"): + assert original_param.prior == converted_param.prior + + +# ===== Test algorithm compatibility ===== + + +def test_neps_only_algorithms(): + """Test that NePS-only algorithms are correctly identified.""" + neps_only_algorithms = [ + algorithms.neps_random_search, + algorithms.neps_hyperband, + algorithms.complex_random_search, + algorithms.neps_priorband, + ] + + for algo in neps_only_algorithms: + compatibility = check_neps_space_compatibility(algo) + assert compatibility in [ + "neps", + "both", + ], f"Algorithm {algo.__name__} should be neps or both compatible" + + +def test_classic_and_both_algorithms(): + """Test that classic algorithms that work with both spaces are correctly identified.""" + both_compatible_algorithms = [ + algorithms.random_search, + algorithms.hyperband, + algorithms.priorband, + ] + + for algo in both_compatible_algorithms: + compatibility = check_neps_space_compatibility(algo) + assert compatibility in [ + "classic", + "both", + ], f"Algorithm {algo.__name__} should be classic or both compatible" + + +def test_algorithm_compatibility_with_string_names(): + """Test algorithm compatibility checking with string names.""" + # Note: String-based compatibility checking may not be fully implemented + # Test with actual algorithm functions instead + + # Test NePS-only algorithms + neps_only_algorithms = [ + algorithms.neps_random_search, + algorithms.neps_hyperband, + algorithms.complex_random_search, + ] + + for algo in neps_only_algorithms: + compatibility = check_neps_space_compatibility(algo) + assert compatibility in [ + "neps", + "both", + ], f"Algorithm {algo.__name__} should be neps or both compatible" + + # Test classic/both algorithms + classic_algorithms = [ + algorithms.random_search, + algorithms.hyperband, + ] + + for algo in classic_algorithms: + compatibility = check_neps_space_compatibility(algo) + assert compatibility in [ + "classic", + "both", + ], f"Algorithm {algo.__name__} should be classic or both compatible" + + +def test_algorithm_compatibility_with_tuples(): + """Test algorithm compatibility checking with tuple configurations.""" + # Test with tuple configuration + neps_config = ("neps_random_search", {"ignore_fidelity": True}) + compatibility = check_neps_space_compatibility(neps_config) + assert compatibility in ["neps", "both"] + + classic_config = ("random_search", {"some_param": "value"}) + compatibility = check_neps_space_compatibility(classic_config) + assert compatibility in ["classic", "both"] + + +def test_auto_algorithm_compatibility(): + """Test that 'auto' algorithm is handled correctly.""" + compatibility = check_neps_space_compatibility("auto") + assert compatibility == "both" + + +# ===== Test NePS hyperband specific functionality ===== + + +def test_neps_hyperband_requires_fidelity(): + """Test that neps_hyperband requires fidelity parameters.""" + # Space without fidelity should fail + space_no_fidelity = SimpleHPOSpace() + + with pytest.raises((ValueError, AssertionError)): + algorithms.neps_hyperband(pipeline_space=space_no_fidelity) + + +def test_neps_hyperband_accepts_fidelity_space(): + """Test that neps_hyperband accepts spaces with fidelity.""" + space_with_fidelity = SimpleHPOWithFidelitySpace() + + # Should not raise an error + optimizer = algorithms.neps_hyperband(pipeline_space=space_with_fidelity) + assert optimizer is not None + + +def test_neps_hyperband_rejects_classic_space(): + """Test that neps_hyperband rejects classic SearchSpace.""" + # Type system should prevent this at compile time + # Instead, test that type checking works as expected + + # Create a proper NePS space that should work + class TestSpace(PipelineSpace): + x = Float(0.0, 1.0) + epochs = Fidelity(Integer(1, 100)) + + space = TestSpace() + + # This should work fine with proper NePS space + optimizer = algorithms.neps_hyperband(pipeline_space=space, eta=3) + assert optimizer is not None + + +@pytest.mark.parametrize("eta", [2, 3, 4, 5]) +def test_neps_hyperband_eta_values(eta): + """Test neps_hyperband with different eta values.""" + space = SimpleHPOWithFidelitySpace() + optimizer = algorithms.neps_hyperband(pipeline_space=space, eta=eta) + assert optimizer is not None + + +@pytest.mark.parametrize("sampler", ["uniform", "prior"]) +def test_neps_hyperband_samplers(sampler): + """Test neps_hyperband with different samplers.""" + space = SimpleHPOWithFidelitySpace() + optimizer = algorithms.neps_hyperband(pipeline_space=space, sampler=sampler) + assert optimizer is not None + + +@pytest.mark.parametrize("sample_prior_first", [False, True, "highest_fidelity"]) +def test_neps_hyperband_sample_prior_first(sample_prior_first): + """Test neps_hyperband with different sample_prior_first options.""" + space = SimpleHPOWithFidelitySpace() + optimizer = algorithms.neps_hyperband( + pipeline_space=space, sample_prior_first=sample_prior_first + ) + assert optimizer is not None + + +# ===== Test space compatibility with different optimizers ===== + + +def test_simple_space_works_with_both_optimizers(): + """Test that simple HPO spaces work with both classic and NePS optimizers.""" + space = SimpleHPOSpace() + + # Should work with NePS-only optimizers + neps_optimizer = algorithms.neps_random_search(pipeline_space=space) + assert neps_optimizer is not None + + # Should also be convertible and work with classic optimizers + converted_space = convert_neps_to_classic_search_space(space) + assert converted_space is not None + + classic_optimizer = algorithms.random_search( + pipeline_space=converted_space, use_priors=True + ) + assert classic_optimizer is not None + + +def test_complex_space_only_works_with_neps_optimizers(): + """Test that complex NePS spaces only work with NePS-compatible optimizers.""" + space = ComplexNepsSpace() + + # Should work with NePS optimizers + neps_optimizer = algorithms.neps_random_search(pipeline_space=space) + assert neps_optimizer is not None + + # Should NOT be convertible to classic + converted_space = convert_neps_to_classic_search_space(space) + assert converted_space is None + + +def test_fidelity_space_compatibility(): + """Test fidelity space compatibility with different optimizers.""" + space = SimpleHPOWithFidelitySpace() + + # Should work with neps_hyperband (requires fidelity) + hyperband_optimizer = algorithms.neps_hyperband(pipeline_space=space) + assert hyperband_optimizer is not None + + # Should also work with other NePS optimizers (but need to ignore fidelity) + random_optimizer = algorithms.neps_random_search( + pipeline_space=space, ignore_fidelity=True + ) + assert random_optimizer is not None + + # Should be convertible to classic for non-neps-specific algorithms + converted_space = convert_neps_to_classic_search_space(space) + assert converted_space is not None + + # Classic hyperband should work with converted space + classic_hyperband = algorithms.hyperband(pipeline_space=converted_space) + assert classic_hyperband is not None + + +# ===== Edge cases and error handling ===== + + +def test_conversion_preserves_log_scaling(): + """Test that log scaling is preserved during conversion.""" + classic_space = neps.SearchSpace( + { + "log_param": neps.HPOFloat(1e-5, 1e-1, log=True), + } + ) + + neps_space = convert_classic_to_neps_search_space(classic_space) + # Access the Float parameter and check if it has a _log attribute + log_param_neps = neps_space.get_attrs()["log_param"] + assert hasattr(log_param_neps, "_log") + assert log_param_neps._log is True + + # Round-trip conversion should now preserve log scaling + converted_back = convert_neps_to_classic_search_space(neps_space) + assert converted_back is not None + # Check the log property specifically for float parameters + log_param = converted_back.elements["log_param"] + assert isinstance(log_param, neps.HPOFloat) + assert log_param.log is True + + +def test_conversion_handles_missing_priors(): + """Test that conversion works correctly when priors are missing.""" + classic_space = neps.SearchSpace( + { + "no_prior": neps.HPOFloat(0.0, 1.0), # No prior specified + } + ) + + neps_space = convert_classic_to_neps_search_space(classic_space) + param = neps_space.get_attrs()["no_prior"] + assert not param.has_prior + + converted_back = convert_neps_to_classic_search_space(neps_space) + assert converted_back is not None + # Check the prior property specifically for float parameters + no_prior_param = converted_back.elements["no_prior"] + assert isinstance(no_prior_param, neps.HPOFloat) + assert no_prior_param.prior is None + + +def test_conversion_handles_empty_spaces(): + """Test that conversion handles edge cases gracefully.""" + # Empty classic space + empty_classic = neps.SearchSpace({}) + neps_space = convert_classic_to_neps_search_space(empty_classic) + assert len(neps_space.get_attrs()) == 0 + + # Convert back + converted_back = convert_neps_to_classic_search_space(neps_space) + assert converted_back is not None + assert len(converted_back.elements) == 0 diff --git a/tests/test_neps_space/test_trajectory_and_metrics.py b/tests/test_neps_space/test_trajectory_and_metrics.py new file mode 100644 index 000000000..91b6de73e --- /dev/null +++ b/tests/test_neps_space/test_trajectory_and_metrics.py @@ -0,0 +1,505 @@ +"""Tests for extended trajectory and metrics functionality in NePS.""" + +from __future__ import annotations + +import re +import tempfile +from pathlib import Path + +import pytest + +import neps +from neps.optimizers import algorithms +from neps.space.neps_spaces.parameters import ( + Fidelity, + Float, + Integer, + PipelineSpace, +) + + +class SimpleSpace(PipelineSpace): + """Simple space for testing metrics functionality.""" + + x = Float(min_value=0.0, max_value=1.0) + y = Integer(min_value=1, max_value=10) + + +class SpaceWithFidelity(PipelineSpace): + """Space with fidelity for testing multi-fidelity metrics.""" + + x = Float(min_value=0.0, max_value=1.0) + y = Integer(min_value=1, max_value=10) + epochs = Fidelity(Integer(min_value=1, max_value=50)) + + +def simple_evaluation(x: float, y: int) -> dict: + """Simple evaluation function that returns multiple metrics.""" + return { + "objective_to_minimize": x + y, + "accuracy": max(0.0, 1.0 - (x + y) / 11.0), # Dummy accuracy metric + "training_time": x * 10 + y, # Dummy training time + "memory_usage": y * 100, # Dummy memory usage + "custom_metric": x * y, # Custom metric + } + + +def fidelity_evaluation(x: float, y: int, epochs: int) -> dict: + """Evaluation function with fidelity that affects metrics.""" + base_objective = x + y + fidelity_factor = epochs / 50.0 # Scale based on fidelity + + return { + "objective_to_minimize": ( + base_objective / fidelity_factor + ), # Better with more epochs + "accuracy": min(1.0, fidelity_factor * (1.0 - base_objective / 11.0)), + "training_time": epochs * (x * 10 + y), # More epochs = more time + "memory_usage": y * 100 + epochs * 10, # Memory increases with epochs + "convergence_rate": 1.0 / epochs, # Faster convergence with more epochs + "epochs_used": epochs, # Track actual epochs used + } + + +def failing_evaluation(x: float, y: int) -> dict: + """Evaluation that sometimes fails to test error handling.""" + if x > 0.8 or y > 8: + raise ValueError("Simulated failure for testing") + + return { + "objective_to_minimize": x + y, + "success_rate": 1.0, + } + + +# ===== Test basic trajectory and metrics ===== + + +def test_basic_trajectory_functionality(): + """Test basic trajectory functionality without checking specific file structure.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "basic_test" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=3, + overwrite_root_directory=True, + ) + + # Check that some optimization files were created + assert root_directory.exists() + + # Find the summary directory and check for result files + summary_dir = root_directory / "summary" + assert summary_dir.exists(), "Summary directory should exist" + + # Check for best config file + best_config_file = summary_dir / "best_config.txt" + assert best_config_file.exists(), "Best config file should exist" + + # Check if trajectory file contains our evaluation results + best_config_content = best_config_file.read_text() + assert "Objective to minimize" in best_config_content # Different casing + + # Check for CSV files that contain the optimization summary + csv_files = list(summary_dir.glob("*.csv")) + assert len(csv_files) > 0, "Should have CSV summary files" + + # Check that basic optimization data is present + csv_content = csv_files[0].read_text() + assert "objective_to_minimize" in csv_content, "Should contain objective values" + + +def test_best_config_with_multiple_metrics(): + """Test that best_config file contains multiple metrics.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "best_config_test" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=5, + overwrite_root_directory=True, + ) + + # Check that best_config file exists + best_config_file = root_directory / "summary" / "best_config.txt" + assert best_config_file.exists(), "Best config file should exist" + + # Read and verify best config contains multiple metrics + best_config_content = best_config_file.read_text() + + # Should contain the primary objective + assert "Objective to minimize" in best_config_content + + # Note: Additional metrics may not be persisted to summary files + # They are used during evaluation but only the main objective is saved + # Should contain configuration parameters + assert ( + "x" in best_config_content or "SAMPLING__Resolvable.x" in best_config_content + ) + assert ( + "y" in best_config_content or "SAMPLING__Resolvable.y" in best_config_content + ) + + +def test_trajectory_with_fidelity(): + """Test trajectory with fidelity-based evaluation.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "fidelity_test" + + # Run optimization with fidelity + neps.run( + evaluate_pipeline=fidelity_evaluation, + pipeline_space=SpaceWithFidelity(), + optimizer=("neps_random_search", {"ignore_fidelity": True}), + root_directory=str(root_directory), + evaluations_to_spend=10, + overwrite_root_directory=True, + ) + + # Check trajectory file + trajectory_file = root_directory / "summary" / "best_config_trajectory.txt" + assert trajectory_file.exists() + + trajectory_content = trajectory_file.read_text() + + # Should contain basic optimization data + assert "Config ID" in trajectory_content + assert "Objective" in trajectory_content + + # Should track configuration parameters (including fidelity if preserved) + assert any( + param in trajectory_content + for param in ["x", "y", "epochs", "SAMPLING__Resolvable"] + ) + + +def test_cumulative_metrics_tracking(): + """Test that cumulative evaluations are tracked in trajectory files.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "cumulative_test" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=5, + overwrite_root_directory=True, + ) + + # Read trajectory + trajectory_file = root_directory / "summary" / "best_config_trajectory.txt" + trajectory_content = trajectory_file.read_text() + + # Should have the expected header + assert ( + "Best configs and their objectives across evaluations:" in trajectory_content + ) + + # Should track cumulative evaluations + assert "Cumulative evaluations:" in trajectory_content + + # Should have multiple config entries (at least some evaluations) + config_count = trajectory_content.count("Config ID:") + assert config_count >= 1, "Should have at least one config entry" + + # Should have objective values + assert "Objective to minimize:" in trajectory_content + + +# ===== Test error handling in metrics ===== + + +def test_trajectory_with_failed_evaluations(): + """Test that trajectory handles failed evaluations correctly.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "error_test" + + # Run optimization that will have some failures + neps.run( + evaluate_pipeline=failing_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=15, # More evaluations to ensure some failures + overwrite_root_directory=True, + ignore_errors=True, # Allow continuing after errors + ) + + # Check that trajectory file exists + trajectory_file = root_directory / "summary" / "best_config_trajectory.txt" + assert trajectory_file.exists() + + # Read trajectory + trajectory_content = trajectory_file.read_text() + lines = trajectory_content.strip().split("\n") + + # Should have at least some successful evaluations + assert len(lines) >= 2 # Header + at least one evaluation + + # Check that errors are handled gracefully + # (The exact behavior may vary, but the file should exist and be readable) + assert "Objective to minimize" in trajectory_content # Different casing + + +# ===== Test hyperband-specific metrics ===== + + +def test_neps_hyperband_metrics(): + """Test that neps_hyperband produces extended metrics.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "hyperband_test" + + # Run neps_hyperband optimization + neps.run( + evaluate_pipeline=fidelity_evaluation, + pipeline_space=SpaceWithFidelity(), + optimizer=algorithms.neps_hyperband, + root_directory=str(root_directory), + fidelities_to_spend=20, # Use fidelities_to_spend for multi-fidelity optimizers + overwrite_root_directory=True, + ) + + # Check trajectory file + trajectory_file = root_directory / "summary" / "best_config_trajectory.txt" + assert trajectory_file.exists() + + trajectory_content = trajectory_file.read_text() + + # Should contain basic optimization data + assert "Objective" in trajectory_content + + # Should contain configuration information + assert any( + param in trajectory_content for param in ["epochs", "SAMPLING__Resolvable"] + ) + + # Should have multiple evaluations with different fidelities + lines = trajectory_content.strip().split("\n") + assert len(lines) >= 5 # Should have some evaluations + + +# ===== Test metrics with different optimizers ===== + + +@pytest.mark.parametrize( + "optimizer", + [ + algorithms.neps_random_search, + algorithms.complex_random_search, + ], +) +def test_metrics_with_different_optimizers(optimizer): + """Test that txt file format is consistent across different optimizers.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / f"optimizer_test_{optimizer.__name__}" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=optimizer, + root_directory=str(root_directory), + evaluations_to_spend=5, + overwrite_root_directory=True, + ) + + # Check files exist + trajectory_file = root_directory / "summary" / "best_config_trajectory.txt" + best_config_file = root_directory / "summary" / "best_config.txt" + + assert trajectory_file.exists() + assert best_config_file.exists() + + # Check contents match expected txt format (only objective_to_minimize is tracked) + trajectory_content = trajectory_file.read_text() + best_config_content = best_config_file.read_text() + + # Both should contain the standard txt file format elements + for content in [trajectory_content, best_config_content]: + assert "Config ID:" in content + assert "Objective to minimize:" in content + assert "Cumulative evaluations:" in content + assert "Config:" in content + + # Trajectory file should have the header + assert ( + "Best configs and their objectives across evaluations:" in trajectory_content + ) + + +# ===== Test metric value validation ===== + + +def test_metric_values_are_reasonable(): + """Test that reported objective values are reasonable in txt files.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "validation_test" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=5, + overwrite_root_directory=True, + ) + + # Read trajectory and parse objective values + trajectory_file = root_directory / "summary" / "best_config_trajectory.txt" + trajectory_content = trajectory_file.read_text() + + # Extract objective values from the actual txt format + objective_matches = re.findall( + r"Objective to minimize: ([\d.]+)", trajectory_content + ) + + # Check that we found some objectives + assert len(objective_matches) > 0, "No objective values found in trajectory" + + # Check each objective value is reasonable + for obj_str in objective_matches: + objective = float(obj_str) + # Objective should be in reasonable range (x+y where x in [0,1], y in [1,10]) + assert 1.0 <= objective <= 11.0, ( + f"Objective {objective} out of expected range [1.0, 11.0]" + ) + + +# ===== Test file format and structure ===== + + +def test_trajectory_file_format(): + """Test that trajectory txt file has correct format.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "format_test" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=3, + overwrite_root_directory=True, + ) + + # Check trajectory file format (txt format, not CSV) + trajectory_file = root_directory / "summary" / "best_config_trajectory.txt" + trajectory_content = trajectory_file.read_text() + + # Should have the expected txt file structure + assert ( + "Best configs and their objectives across evaluations:" in trajectory_content + ) + assert "Config ID:" in trajectory_content + assert "Objective to minimize:" in trajectory_content + assert "Cumulative evaluations:" in trajectory_content + assert "Config:" in trajectory_content + + # Should have separator lines + assert ( + "--------------------------------------------------------------------------------" + in trajectory_content + ) + + +def test_results_directory_structure(): + """Test that results directory has expected structure.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "structure_test" + + # Run optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=3, + overwrite_root_directory=True, + ) + + # Check directory structure + results_dir = root_directory / "summary" + assert results_dir.exists() + assert results_dir.is_dir() + + # Check expected files + expected_files = ["best_config_trajectory.txt", "best_config.txt"] + for filename in expected_files: + file_path = results_dir / filename + assert file_path.exists(), f"Expected file {filename} should exist" + assert file_path.is_file(), f"{filename} should be a file" + + # File should not be empty + content = file_path.read_text() + assert len(content.strip()) > 0, f"{filename} should not be empty" + + +def test_neps_revisit_run_with_trajectory(): + """Test that NePS can revisit an earlier run and use incumbent trajectory.""" + with tempfile.TemporaryDirectory() as tmp_dir: + root_directory = Path(tmp_dir) / "revisit_test" + + # First run - create initial optimization + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=3, + overwrite_root_directory=True, # Start fresh + ) + + # Check that initial files were created + summary_dir = root_directory / "summary" + assert summary_dir.exists() + best_config_file = summary_dir / "best_config.txt" + trajectory_file = summary_dir / "best_config_trajectory.txt" + assert best_config_file.exists() + assert trajectory_file.exists() + + # Read initial trajectory + initial_trajectory = trajectory_file.read_text() + assert "Config ID:" in initial_trajectory + assert "Objective to minimize:" in initial_trajectory + + # Second run - revisit without overwriting + neps.run( + evaluate_pipeline=simple_evaluation, + pipeline_space=SimpleSpace(), + optimizer=algorithms.neps_random_search, + root_directory=str(root_directory), + evaluations_to_spend=2, # Add 2 more evaluations + overwrite_root_directory=False, # Don't overwrite, continue from previous + ) + + # Check that trajectory was updated with new evaluations + updated_trajectory = trajectory_file.read_text() + + # The updated trajectory should contain the original entries plus new ones + assert len(updated_trajectory) >= len(initial_trajectory) + assert "Config ID:" in updated_trajectory + assert "Objective to minimize:" in updated_trajectory + + # Should have evidence of multiple evaluations + # Note: trajectory.txt only tracks BEST configs, not all evaluations + # So we check that the files still have the expected format and content + assert "Config ID:" in updated_trajectory + assert "Objective to minimize:" in updated_trajectory + + # The updated content should be at least as long (potentially with timing info added) + assert len(updated_trajectory) >= len(initial_trajectory), ( + "Updated trajectory should have at least the same content" + ) diff --git a/tests/test_neps_space/utils.py b/tests/test_neps_space/utils.py new file mode 100644 index 000000000..bf7420c3e --- /dev/null +++ b/tests/test_neps_space/utils.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from collections.abc import Callable + +from neps.space.neps_spaces import neps_space + + +def generate_possible_config_strings( + pipeline: neps_space.PipelineSpace, + resolved_pipeline_attr_getter: Callable[ + [neps_space.PipelineSpace], + neps_space.Operation, + ], + num_resolutions: int = 50_000, +): + result = set() + + for _ in range(num_resolutions): + resolved_pipeline, _resolution_context = neps_space.resolve(pipeline) + attr = resolved_pipeline_attr_getter(resolved_pipeline) + config_string = neps_space.convert_operation_to_string(attr) + result.add(config_string) + + return result diff --git a/tests/test_runtime/test_default_report_values.py b/tests/test_runtime/test_default_report_values.py index 45ad5e5cc..b07ef274b 100644 --- a/tests/test_runtime/test_default_report_values.py +++ b/tests/test_runtime/test_default_report_values.py @@ -7,7 +7,7 @@ from neps.optimizers import OptimizerInfo from neps.optimizers.algorithms import random_search from neps.runtime import DefaultWorker -from neps.space import Float, SearchSpace +from neps.space import HPOFloat, SearchSpace from neps.state import ( DefaultReportValues, NePSState, @@ -33,7 +33,7 @@ def neps_state(tmp_path: Path) -> NePSState: def test_default_values_on_error( neps_state: NePSState, ) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues( @@ -86,7 +86,7 @@ def eval_function(*args, **kwargs) -> float: def test_default_values_on_not_specified( neps_state: NePSState, ) -> None: - optimizer = random_search(SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues( @@ -137,7 +137,7 @@ def eval_function(*args, **kwargs) -> float: def test_default_value_objective_to_minimize_curve_take_objective_to_minimize_value( neps_state: NePSState, ) -> None: - optimizer = random_search(SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues( diff --git a/tests/test_runtime/test_error_handling_strategies.py b/tests/test_runtime/test_error_handling_strategies.py index 0549f87b5..a7479ca64 100644 --- a/tests/test_runtime/test_error_handling_strategies.py +++ b/tests/test_runtime/test_error_handling_strategies.py @@ -11,7 +11,7 @@ from neps.optimizers import OptimizerInfo from neps.optimizers.algorithms import random_search from neps.runtime import DefaultWorker -from neps.space import Float, SearchSpace +from neps.space import HPOFloat, SearchSpace from neps.state import ( DefaultReportValues, NePSState, @@ -44,7 +44,7 @@ def test_worker_raises_when_error_in_self( neps_state: NePSState, on_error: OnErrorPossibilities, ) -> None: - optimizer = random_search(SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=on_error, # <- Highlight default_report_values=DefaultReportValues(), @@ -85,7 +85,7 @@ def eval_function(*args, **kwargs) -> float: def test_worker_raises_when_error_in_other_worker(neps_state: NePSState) -> None: - optimizer = random_search(SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.RAISE_ANY_ERROR, # <- Highlight default_report_values=DefaultReportValues(), @@ -146,7 +146,7 @@ def test_worker_does_not_raise_when_error_in_other_worker( neps_state: NePSState, on_error: OnErrorPossibilities, ) -> None: - optimizer = random_search(SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=on_error, # <- Highlight default_report_values=DefaultReportValues(), diff --git a/tests/test_runtime/test_save_evaluation_results.py b/tests/test_runtime/test_save_evaluation_results.py index b0db8b163..f82977966 100644 --- a/tests/test_runtime/test_save_evaluation_results.py +++ b/tests/test_runtime/test_save_evaluation_results.py @@ -4,11 +4,10 @@ from pytest_cases import fixture -from neps import save_pipeline_results +from neps import Float, PipelineSpace, save_pipeline_results from neps.optimizers import OptimizerInfo from neps.optimizers.algorithms import random_search from neps.runtime import DefaultWorker -from neps.space import Float, SearchSpace from neps.state import ( DefaultReportValues, NePSState, @@ -31,8 +30,12 @@ def neps_state(tmp_path: Path) -> NePSState: ) +class ASpace(PipelineSpace): + a = Float(0, 1) + + def test_async_happy_path_changes_state(neps_state: NePSState) -> None: - optimizer = random_search(SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(ASpace()) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues( diff --git a/tests/test_runtime/test_stopping_criterion.py b/tests/test_runtime/test_stopping_criterion.py index 67f15365f..cd75cb089 100644 --- a/tests/test_runtime/test_stopping_criterion.py +++ b/tests/test_runtime/test_stopping_criterion.py @@ -8,7 +8,7 @@ from neps.optimizers.algorithms import random_search from neps.optimizers.optimizer import OptimizerInfo from neps.runtime import DefaultWorker -from neps.space import Float, SearchSpace +from neps.space import HPOFloat, SearchSpace from neps.state import ( DefaultReportValues, NePSState, @@ -36,7 +36,7 @@ def neps_state(tmp_path: Path) -> NePSState: def test_max_evaluations_total_stopping_criterion( neps_state: NePSState, ) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues(), @@ -89,7 +89,7 @@ def eval_function(*args, **kwargs) -> float: def test_worker_evaluations_total_stopping_criterion( neps_state: NePSState, ) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues(), @@ -151,7 +151,7 @@ def eval_function(*args, **kwargs) -> float: def test_include_in_progress_evaluations_towards_maximum_with_work_eval_count( neps_state: NePSState, ) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues(), @@ -206,7 +206,7 @@ def eval_function(*args, **kwargs) -> float: def test_max_cost_total(neps_state: NePSState) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues(), @@ -255,7 +255,7 @@ def eval_function(*args, **kwargs) -> dict: def test_worker_cost_total(neps_state: NePSState) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues(), @@ -312,7 +312,7 @@ def eval_function(*args, **kwargs) -> dict: def test_worker_wallclock_time(neps_state: NePSState) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues(), @@ -368,7 +368,7 @@ def eval_function(*args, **kwargs) -> float: def test_max_worker_evaluation_time(neps_state: NePSState) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues(), @@ -425,7 +425,7 @@ def eval_function(*args, **kwargs) -> float: def test_max_evaluation_time_global(neps_state: NePSState) -> None: - optimizer = random_search(pipeline_space=SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(pipeline_space=SearchSpace({"a": HPOFloat(0, 1)})) settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, default_report_values=DefaultReportValues(), diff --git a/tests/test_runtime/test_worker_creation.py b/tests/test_runtime/test_worker_creation.py index 4b741640a..9b71f5855 100644 --- a/tests/test_runtime/test_worker_creation.py +++ b/tests/test_runtime/test_worker_creation.py @@ -4,6 +4,7 @@ import pytest +from neps import Float, PipelineSpace from neps.optimizers import OptimizerInfo from neps.optimizers.algorithms import random_search from neps.runtime import ( @@ -12,7 +13,6 @@ OnErrorPossibilities, WorkerSettings, ) -from neps.space import Float, SearchSpace from neps.state import NePSState, OptimizationState, SeedSnapshot @@ -27,6 +27,10 @@ def neps_state(tmp_path: Path) -> NePSState: ) +class ASpace(PipelineSpace): + a = Float(0, 1) + + def test_create_worker_manual_id(neps_state: NePSState) -> None: settings = WorkerSettings( on_error=OnErrorPossibilities.IGNORE, @@ -47,7 +51,8 @@ def eval_fn(config: dict) -> float: return 1.0 test_worker_id = "my_worker_123" - optimizer = random_search(SearchSpace({"a": Float(0, 1)})) + + optimizer = random_search(ASpace()) worker = DefaultWorker.new( state=neps_state, @@ -80,7 +85,7 @@ def test_create_worker_auto_id(neps_state: NePSState) -> None: def eval_fn(config: dict) -> float: return 1.0 - optimizer = random_search(SearchSpace({"a": Float(0, 1)})) + optimizer = random_search(ASpace()) worker = DefaultWorker.new( state=neps_state, diff --git a/tests/test_search_space.py b/tests/test_search_space.py index 73073a0cc..7182463fa 100644 --- a/tests/test_search_space.py +++ b/tests/test_search_space.py @@ -2,12 +2,17 @@ import pytest -from neps import Categorical, Constant, Float, Integer, SearchSpace +from neps.space import SearchSpace +from neps.space.parameters import HPOCategorical, HPOConstant, HPOFloat, HPOInteger def test_search_space_orders_parameters_by_name(): - unsorted = SearchSpace({"b": Float(0, 1), "c": Float(0, 1), "a": Float(0, 1)}) - expected = SearchSpace({"a": Float(0, 1), "b": Float(0, 1), "c": Float(0, 1)}) + unsorted = SearchSpace( + {"b": HPOFloat(0, 1), "c": HPOFloat(0, 1), "a": HPOFloat(0, 1)} + ) + expected = SearchSpace( + {"a": HPOFloat(0, 1), "b": HPOFloat(0, 1), "c": HPOFloat(0, 1)} + ) assert unsorted == expected @@ -15,17 +20,20 @@ def test_multipe_fidelities_raises_error(): # We should allow this at some point, but until we do, raise an error with pytest.raises(ValueError, match="neps only supports one fidelity parameter"): SearchSpace( - {"a": Float(0, 1, is_fidelity=True), "b": Float(0, 1, is_fidelity=True)} + { + "a": HPOFloat(0, 1, is_fidelity=True), + "b": HPOFloat(0, 1, is_fidelity=True), + } ) def test_sorting_of_parameters_into_subsets(): elements = { - "a": Float(0, 1), - "b": Integer(0, 10), - "c": Categorical(["a", "b", "c"]), - "d": Float(0, 1, is_fidelity=True), - "x": Constant("x"), + "a": HPOFloat(0, 1), + "b": HPOInteger(0, 10), + "c": HPOCategorical(["a", "b", "c"]), + "d": HPOFloat(0, 1, is_fidelity=True), + "x": HPOConstant("x"), } space = SearchSpace(elements) assert space.elements == elements diff --git a/tests/test_search_space_parsing.py b/tests/test_search_space_parsing.py index 4fd2ea226..b94eb9781 100644 --- a/tests/test_search_space_parsing.py +++ b/tests/test_search_space_parsing.py @@ -4,7 +4,14 @@ import pytest -from neps.space import Categorical, Constant, Float, Integer, Parameter, parsing +from neps.space import ( + HPOCategorical, + HPOConstant, + HPOFloat, + HPOInteger, + Parameter, + parsing, +) @pytest.mark.parametrize( @@ -12,27 +19,27 @@ [ ( (0, 1), - Integer(0, 1), + HPOInteger(0, 1), ), ( ("1e3", "1e5"), - Integer(1e3, 1e5), + HPOInteger(1e3, 1e5), ), ( ("1e-3", "1e-1"), - Float(1e-3, 1e-1), + HPOFloat(1e-3, 1e-1), ), ( (1e-5, 1e-1), - Float(1e-5, 1e-1), + HPOFloat(1e-5, 1e-1), ), ( {"type": "float", "lower": 0.00001, "upper": "1e-1", "log": True}, - Float(0.00001, 0.1, log=True), + HPOFloat(0.00001, 0.1, log=True), ), ( {"type": "int", "lower": 3, "upper": 30, "is_fidelity": True}, - Integer(3, 30, is_fidelity=True), + HPOInteger(3, 30, is_fidelity=True), ), ( { @@ -42,27 +49,27 @@ "log": True, "is_fidelity": False, }, - Integer(100, 30000, log=True, is_fidelity=False), + HPOInteger(100, 30000, log=True, is_fidelity=False), ), ( {"type": "float", "lower": "3.3e-5", "upper": "1.5E-1"}, - Float(3.3e-5, 1.5e-1), + HPOFloat(3.3e-5, 1.5e-1), ), ( {"type": "cat", "choices": [2, "sgd", "10e-3"]}, - Categorical([2, "sgd", 0.01]), + HPOCategorical([2, "sgd", 0.01]), ), ( 0.5, - Constant(0.5), + HPOConstant(0.5), ), ( "1e3", - Constant(1000), + HPOConstant(1000), ), ( {"type": "cat", "choices": ["adam", "sgd", "rmsprop"]}, - Categorical(["adam", "sgd", "rmsprop"]), + HPOCategorical(["adam", "sgd", "rmsprop"]), ), ( { @@ -72,7 +79,7 @@ "prior": 3.3e-2, "prior_confidence": "high", }, - Float(0.00001, 0.1, log=True, prior=3.3e-2, prior_confidence="high"), + HPOFloat(0.00001, 0.1, log=True, prior=3.3e-2, prior_confidence="high"), ), ], ) diff --git a/tests/test_state/test_neps_state.py b/tests/test_state/test_neps_state.py index bf7512fae..cb59800a3 100644 --- a/tests/test_state/test_neps_state.py +++ b/tests/test_state/test_neps_state.py @@ -12,6 +12,7 @@ import pytest from pytest_cases import case, fixture, parametrize, parametrize_with_cases +import neps from neps.optimizers import ( AskFunction, OptimizerInfo, @@ -19,64 +20,61 @@ load_optimizer, ) from neps.optimizers.ask_and_tell import AskAndTell -from neps.space import ( +from neps.space import SearchSpace +from neps.space.neps_spaces.parameters import ( Categorical, - Constant, + Fidelity, Float, Integer, - SearchSpace, + PipelineSpace, ) from neps.state import BudgetInfo, NePSState, OptimizationState, SeedSnapshot @case -def case_search_space_no_fid() -> SearchSpace: - return SearchSpace( - { - "a": Float(0, 1), - "b": Categorical(["a", "b", "c"]), - "c": Constant("a"), - "d": Integer(0, 10), - } - ) +def case_search_space_no_fid() -> PipelineSpace: + class Space(PipelineSpace): + a = Float(0, 1) + b = Categorical(("a", "b", "c")) + c = "a" + d = Integer(0, 10) + + return Space() @case -def case_search_space_with_fid() -> SearchSpace: - return SearchSpace( - { - "a": Float(0, 1), - "b": Categorical(["a", "b", "c"]), - "c": Constant("a"), - "d": Integer(0, 10), - "e": Integer(1, 10, is_fidelity=True), - } - ) +def case_search_space_with_fid() -> PipelineSpace: + class SpaceFid(PipelineSpace): + a = Float(0, 1) + b = Categorical(("a", "b", "c")) + c = "a" + d = Integer(0, 10) + e = Fidelity(Integer(1, 10)) + + return SpaceFid() @case -def case_search_space_no_fid_with_prior() -> SearchSpace: - return SearchSpace( - { - "a": Float(0, 1, prior=0.5), - "b": Categorical(["a", "b", "c"], prior="a"), - "c": Constant("a"), - "d": Integer(0, 10, prior=5), - } - ) +def case_search_space_no_fid_with_prior() -> PipelineSpace: + class SpacePrior(PipelineSpace): + a = Float(0, 1, prior=0.5, prior_confidence="medium") + b = Categorical(("a", "b", "c"), prior=0, prior_confidence="medium") + c = "a" + d = Integer(0, 10, prior=5, prior_confidence="medium") + + return SpacePrior() @case -def case_search_space_fid_with_prior() -> SearchSpace: - return SearchSpace( - { - "a": Float(0, 1, prior=0.5), - "b": Categorical(["a", "b", "c"], prior="a"), - "c": Constant("a"), - "d": Integer(0, 10, prior=5), - "e": Integer(1, 10, is_fidelity=True), - } - ) +def case_search_space_fid_with_prior() -> PipelineSpace: + class SpaceFidPrior(PipelineSpace): + a = Float(0, 1, prior=0.5, prior_confidence="medium") + b = Categorical(("a", "b", "c"), prior=0, prior_confidence="medium") + c = "a" + d = Integer(0, 10, prior=5, prior_confidence="medium") + e = Fidelity(Integer(1, 10)) + + return SpaceFidPrior() # See issue #121 @@ -96,12 +94,18 @@ def case_search_space_fid_with_prior() -> SearchSpace: "async_hb", "ifbo", "priorband", + "moasha", + "mo_hyperband", + "neps_priorband", + "neps_hyperband", ] NO_DEFAULT_FIDELITY_SUPPORT = [ "random_search", "grid_search", "bayesian_optimization", "pibo", + "neps_random_search", + "complex_random_search", ] NO_DEFAULT_PRIOR_SUPPORT = [ "grid_search", @@ -112,6 +116,10 @@ def case_search_space_fid_with_prior() -> SearchSpace: "hyperband", "async_hb", "random_search", + "moasha", + "mo_hyperband", + "neps_random_search", + "complex_random_search", ] REQUIRES_PRIOR = [ "pibo", @@ -127,33 +135,42 @@ def case_search_space_fid_with_prior() -> SearchSpace: "primo", ] +REQUIRES_NEPS_SPACE = [ + "neps_priorband", + "neps_random_search", + "complex_random_search", + "neps_hyperband", +] + @fixture @parametrize("key", list(PredefinedOptimizers.keys())) @parametrize_with_cases("search_space", cases=".", prefix="case_search_space") def optimizer_and_key_and_search_space( - key: str, search_space: SearchSpace -) -> tuple[AskFunction, str, SearchSpace]: + key: str, search_space: PipelineSpace +) -> tuple[AskFunction, str, PipelineSpace | SearchSpace]: if key in JUST_SKIP: pytest.xfail(f"{key} is not instantiable") if key in NO_DEFAULT_PRIOR_SUPPORT and any( - parameter.prior is not None for parameter in search_space.searchables.values() + parameter.has_prior if hasattr(parameter, "has_prior") else False + for parameter in search_space.get_attrs().values() ): pytest.xfail(f"{key} crashed with a prior") - if search_space.fidelity is not None and key in NO_DEFAULT_FIDELITY_SUPPORT: + if search_space.fidelity_attrs and key in NO_DEFAULT_FIDELITY_SUPPORT: pytest.xfail(f"{key} crashed with a fidelity") - if key in REQUIRES_FIDELITY and search_space.fidelity is None: + if key in REQUIRES_FIDELITY and not search_space.fidelity_attrs: pytest.xfail(f"{key} requires a fidelity parameter") - if key in REQUIRES_PRIOR and all( - parameter.prior is None for parameter in search_space.searchables.values() + if key in REQUIRES_PRIOR and not any( + parameter.has_prior if hasattr(parameter, "has_prior") else False + for parameter in search_space.get_attrs().values() ): pytest.xfail(f"{key} requires a prior") - if key in REQUIRES_FIDELITY_MO and search_space.fidelity is None: + if key in REQUIRES_FIDELITY_MO and not search_space.fidelity_attrs: pytest.xfail(f"Multi-objective optimizer {key} requires a fidelity parameter") if key in REQUIRES_MO_PRIOR: @@ -161,7 +178,20 @@ def optimizer_and_key_and_search_space( kwargs: dict[str, Any] = {} opt, _ = load_optimizer((key, kwargs), search_space) # type: ignore - return opt, key, search_space + converted_space = ( + neps.space.neps_spaces.neps_space.convert_neps_to_classic_search_space( + search_space + ) + ) + return ( + opt, + key, + ( + converted_space + if converted_space and key not in REQUIRES_NEPS_SPACE + else search_space + ), + ) @parametrize("optimizer_info", [OptimizerInfo(name="blah", info={"a": "b"})]) @@ -188,7 +218,9 @@ def case_neps_state_filebased( @parametrize_with_cases("neps_state", cases=".", prefix="case_neps_state") def test_sample_trial( neps_state: NePSState, - optimizer_and_key_and_search_space: tuple[AskFunction, str, SearchSpace], + optimizer_and_key_and_search_space: tuple[ + AskFunction, str, PipelineSpace | SearchSpace + ], capsys, ) -> None: optimizer, key, search_space = optimizer_and_key_and_search_space @@ -202,8 +234,24 @@ def test_sample_trial( for k, v in trial1.config.items(): assert v is not None, f"'{k}' is None in {trial1.config}" - for name in search_space: - assert name in trial1.config, f"'{name}' is not in {trial1.config}" + if isinstance(search_space, SearchSpace): + for name in search_space: + assert name in trial1.config, f"'{name}' is not in {trial1.config}" + else: + config = neps.space.neps_spaces.neps_space.NepsCompatConverter().from_neps_config( + trial1.config + ) + resolved_pipeline, _ = neps.space.neps_spaces.neps_space.resolve( + pipeline=search_space, + domain_sampler=neps.space.neps_spaces.neps_space.OnlyPredefinedValuesSampler( + predefined_samplings=config.predefined_samplings + ), + environment_values=config.environment_values, + ) + for name in search_space.get_attrs(): + assert name in resolved_pipeline.get_attrs(), ( + f"'{name}' is not in {resolved_pipeline.get_attrs()}" + ) # HACK: Unfortunatly due to windows, who's time.time() is not very # precise, we need to introduce a sleep -_- @@ -218,8 +266,24 @@ def test_sample_trial( for k, v in trial1.config.items(): assert v is not None, f"'{k}' is None in {trial1.config}" - for name in search_space: - assert name in trial1.config, f"'{name}' is not in {trial1.config}" + if isinstance(search_space, SearchSpace): + for name in search_space: + assert name in trial1.config, f"'{name}' is not in {trial1.config}" + else: + config = neps.space.neps_spaces.neps_space.NepsCompatConverter().from_neps_config( + trial1.config + ) + resolved_pipeline, _ = neps.space.neps_spaces.neps_space.resolve( + pipeline=search_space, + domain_sampler=neps.space.neps_spaces.neps_space.OnlyPredefinedValuesSampler( + predefined_samplings=config.predefined_samplings + ), + environment_values=config.environment_values, + ) + for name in search_space.get_attrs(): + assert name in resolved_pipeline.get_attrs(), ( + f"'{name}' is not in {resolved_pipeline.get_attrs()}" + ) assert trial1 != trial2 @@ -230,7 +294,9 @@ def test_sample_trial( def test_optimizers_work_roughly( - optimizer_and_key_and_search_space: tuple[AskFunction, str, SearchSpace], + optimizer_and_key_and_search_space: tuple[ + AskFunction, str, PipelineSpace | SearchSpace + ], ) -> None: opt, key, search_space = optimizer_and_key_and_search_space ask_and_tell = AskAndTell(opt)