# Search Spaces

The term "search space" refers to the domain of possible values for the parameters that are being optimized during a campaign. A search space represents the space within which BayBE explores and searches for the optimal solution. It is implemented via the [`SearchSpace`](baybe.searchspace.core.SearchSpace) class.

Note that a search space is not necessarily equal to the space of allowed measurements. That is, if configured properly, it is possible to add measurements to a campaign that are not part of the search space. For instance, a numerical parameter with values `1.0`, `2.0`, `5.0` will create a searchspace with these numbers, but you can also add measurements where the parameter has a value of e.g. `2.12`.

In BayBE, a search space is a union of two (potentially empty) subspaces. The [`SubspaceDiscrete`](baybe.searchspace.discrete.SubspaceDiscrete) contains all discrete parameters, while the [`SubspaceContinuous`](baybe.searchspace.continuous.SubspaceContinuous) contains all continuous parameters.

Depending on which of the subspaces are non-empty, a `SearchSpace` has exactly one of the three [`SearchSpaceType`](baybe.searchspace.core.SearchSpaceType)'s:

|`SubspaceDiscrete`|`SubspaceContinuous`|[`SearchSpaceType`](baybe.searchspace.core.SearchSpaceType)|
|------------------|--------------------|-----------------|
|Non-empty|Empty|[`SearchSpaceType.DISCRETE`](baybe.searchspace.core.SearchSpaceType.DISCRETE)|
|Empty|Non-Empty|[`SearchSpaceType.CONTINUOUS`](baybe.searchspace.core.SearchSpaceType.CONTINUOUS)|
|Non-Empty|Non-empty|[`SearchSpaceType.HYBRID`](baybe.searchspace.core.SearchSpaceType.HYBRID)|


## Discrete Subspaces

The `SubspaceDiscrete` contains all the discrete parameters of a `SearchSpace`. There are different ways of constructing this subspace.

### Building from the Product of Parameter Values

The method [`SearchSpace.from_product`](baybe.searchspace.discrete.SubspaceDiscrete.from_product) constructs the full cartesian product of the provided parameters:

```python
from baybe.parameters import NumericalDiscreteParameter, CategoricalParameter
from baybe.searchspace import SubspaceDiscrete

parameters = [
    NumericalDiscreteParameter(name="x0", values=[1, 2, 3]),
    NumericalDiscreteParameter(name="x1", values=[4, 5, 6]),
    CategoricalParameter(name="Speed", values=["slow", "normal", "fast"]),
]
subspace = SubspaceDiscrete.from_product(parameters=parameters)
```

In this example, `subspace` has a total of 27 different parameter configuration.

~~~
      x0   x1   Speed
 0   1.0  4.0    slow
 1   1.0  4.0  normal
 2   1.0  4.0    fast
 ..  ...  ...     ...
 24  3.0  6.0    slow
 25  3.0  6.0  normal
 26  3.0  6.0    fast
  
  [27 rows x 3 columns]
~~~

### Constructing from a Dataframe

[`SubspaceDiscrete.from_dataframe`](baybe.searchspace.discrete.SubspaceDiscrete.from_dataframe) constructs a discrete subspace from a given dataframe.
By default, this method tries to infer the data column as as a [`NumericalDiscreteParameter`](baybe.parameters.numerical.NumericalDiscreteParameter) and uses [`CategoricalParameter`](baybe.parameters.categorical.CategoricalParameter) as fallback.
However, it is possible to change this behavior by using the optional `parameters` keyword.
This list informs `from_dataframe` about the parameters and the types of parameters that should be used.
In particular, it is necessary to provide such a list if there are non-numerical parameters that should not be interpreted as categorical parameters.

```python
import pandas as pd

df = pd.DataFrame(
    {
        "x0": [2, 3, 3],
        "x1": [5, 4, 6],
        "x2": [9, 7, 9],
    }
)
subspace = SubspaceDiscrete.from_dataframe(df)
```

~~~
 Discrete Parameters
   Name                        Type  Num_Values Encoding
 0   x0  NumericalDiscreteParameter           2     None
 1   x1  NumericalDiscreteParameter           3     None
 2   x2  NumericalDiscreteParameter           2     None
~~~

### Creating a Simplex-Bound Discrete Subspace

[`SubspaceDiscrete.from_simplex`](baybe.searchspace.discrete.SubspaceDiscrete.from_simplex) can be used to efficiently create a discrete search space (or discrete subspace) that is restricted by a simplex constraint, limiting the maximum sum of the parameters per dimension.
This method uses a shortcut that removes invalid candidates already during the creation of parameter combinations and avoids to first create the full product space before filtering it.

In the following example, a naive construction of the subspace would first construct the full product space, containing 25 points, although only 15 points are actually part of the simplex.

```python
parameters = [
    NumericalDiscreteParameter(name="p1", values=[0, 0.25, 0.5, 0.75, 1]),
    NumericalDiscreteParameter(name="p2", values=[0, 0.25, 0.5, 0.75, 1]),
]
subspace = SubspaceDiscrete.from_simplex(max_sum=1.0, simplex_parameters=parameters)
```

~~~
       p1    p2
 0   0.00  0.00
 1   0.00  0.25
 2   0.00  0.50
 ..   ...   ...
 12  0.75  0.00
 13  0.75  0.25
 14  1.00  0.00
 
 [15 rows x 2 columns]
~~~

Note that it is also possible to provide additional parameters that then enter in the form of a Cartesian product.
These can be provided via the keyword `product_parameters`.

### Representation of Data within Discrete Subspaces

Internally, discrete subspaces are represented by two dataframes, the *experimental* and the *computational* representation.

The experimental representation (`exp_rep`) contains all parameters as they were provided upon the construction of the search space and viewed by the experimenter. The computational representation (`comp_rep`) contains a representation of parameters that is actually used for the internal calculation.

In particular, the computational representation contains no more labels or constant columns. This happens e.g. for [`SubstanceParameter`](baybe.parameters.substance.SubstanceParameter) or [`CategoricalParameter`](baybe.parameters.categorical.CategoricalParameter). Further, note that the shape of the computational representation can also change depending on the chosen encoding.

The following example demonstrates the difference:
```python
from baybe.parameters import NumericalDiscreteParameter, CategoricalParameter

speed = CategoricalParameter("Speed", values=["slow", "normal", "fast"], encoding="OHE")
temperature = NumericalDiscreteParameter(name="Temperature", values=[90, 105])

subspace = SubspaceDiscrete.from_product(parameters=[speed, temperature])
```

~~~
  Experimental Representation
      Speed  Temperature
  0    slow         90.0
  1    slow        105.0
  2  normal         90.0
  3  normal        105.0
  4    fast         90.0
  5    fast        105.0

  Computational Representation
     Speed_slow  Speed_normal  Speed_fast  Temperature
  0           1             0           0         90.0
  1           1             0           0        105.0
  2           0             1           0         90.0
  3           0             1           0        105.0
  4           0             0           1         90.0
  5           0             0           1        105.0
~~~

### Metadata

```{warning}
Although possible, it is not intended to manually change the metadata. The metadata is maintained internally, and there is a risk involved with manipulating it manually.
Consequently, we advise to only change the metadata manually if you are certain about it.
```

Discrete subspaces keep track of the recommendations that were made during a campaign.
The information is stored in a [`metadata`](baybe.searchspace.discrete.SubspaceDiscrete.metadata) field, and it is possible to manually modify `metadata` to influence the behavior of the corresponding `campaign` exploring the space. In particular, by manually changing the values of `metadata["dont_recommend"]`, it is possible to prevent certain points of the subspace from being recommended.

## Continuous Subspaces

The `SubspaceContinuous` contains all the continuous parameters of a `SearchSpace`. There are different ways of constructing this subspace.

### Using Explicit Bounds

The [`SubspaceContinuous.from_bounds`](baybe.searchspace.continuous.SubspaceContinuous.from_bounds) method can be used to easily create a subspace representing a hyperrectangle.

```python
from baybe.searchspace import SubspaceContinuous

bounds = pd.DataFrame({"param1": [0, 1], "param2": [-1, 1]})
subspace = continuous = SubspaceContinuous.from_bounds(bounds)
```

~~~
 Continuous Parameters
      Name                          Type  Lower_Bound  Upper_Bound
 0  param1  NumericalContinuousParameter          0.0          1.0
 1  param2  NumericalContinuousParameter         -1.0          1.0
~~~

### Constructing from a Dataframe

Similar to discrete subspaces, continuous spaces can also be constructed using [`SubspaceContinuous.from_dataframe`](baybe.searchspace.continuous.SubspaceContinuous.from_dataframe).
However, when using this method to create a continuous space, it will create the smallest axis-aligned hyperrectangle-shaped continuous subspace that contains the points specified in the given dataframe.

```python
from baybe.parameters import NumericalContinuousParameter
from baybe.searchspace.continuous import SubspaceContinuous

points = pd.DataFrame(
    {
        "param1": [0, 1, 2],
        "param2": [-1, 0, 1],
    }
)
subspace = SubspaceContinuous.from_dataframe(df=points)
```

As for discrete subspaces, this method automatically infers the parameter types but can be provided with an optional list `parameters`.

~~~
 Continuous Parameters
      Name                          Type  Lower_Bound  Upper_Bound
 0  param1  NumericalContinuousParameter          0.0          2.0
 1  param2  NumericalContinuousParameter         -1.0          1.0
~~~

## Constructing Full Search Spaces

There are several methods available for creating full search spaces.

### From the Default Constructor 

It is possible to construct a search space by simply using the default constructor of the `SearchSpace` class.
The required parameters are derived from the `__init__` function of that class.
In the simplest setting, it is sufficient to provide a single subspace for creating either a discrete or continuous search, or provide two subspaces for creating a hybrid search space.

~~~python
searchspace = SearchSpace(discrete=discrete_subspace, continuous=continuous_subspace)
~~~

While this constructor is the default choice, it might not be the most convenient.
Consequently, other constructors are available.

### Building from the Product of Parameter Values

The function [`SearchSpace.from_product`](baybe.searchspace.core.SearchSpace.from_product) is analog to the corresponding function available for `SubspaceDiscrete`, but allows the parameter list to contain both discrete and continuous parameters.

### Constructing from a Dataframe

[`SearchSpace.from_dataframe`](baybe.searchspace.core.SearchSpace.from_dataframe) constructs a search space from a given dataframe.
Due to the ambiguity between discrete and numerical parameter choices, this function requires a parameters list with pre-defined parameter objects, unlike its subspace counterparts, where this list is optional.

```python
from baybe.searchspace import SearchSpace

params = [
    NumericalDiscreteParameter(name="x0", values=[1, 2, 3]),
    NumericalDiscreteParameter(name="x1", values=[4, 5, 6]),
    NumericalContinuousParameter(name="x2", bounds=(6, 9)),
]

df = pd.DataFrame(
    {
        "x0": [2, 3],
        "x1": [5, 4],
        "x2": [9, 7],
    }
)
searchspace = SearchSpace.from_dataframe(df=df, parameters=params)
```

Since one of the provided parameters is continuous, this creates a hybrid space.
The following shows *all* information that are available to the user for this space:

~~~
Search Space
         
 Search Space Type: HYBRID
         
 Discrete Search Space
              
  Discrete Parameters
    Name                        Type  Num_Values Encoding
  0   x0  NumericalDiscreteParameter           3     None
  1   x1  NumericalDiscreteParameter           3     None
              
  Experimental Representation
     x0  x1   
  0   2   5
  1   3   4
  
  Metadata:
  was_recommended: 0/2
  was_measured: 0/2
  dont_recommend: 0/2
              
  Constraints
  Empty DataFrame
  Columns: []
  Index: []
              
  Computational Representation
     x0  x1   
  0   2   5
  1   3   4
         
 Continuous Search Space
              
  Continuous Parameters
    Name                          Type  Lower_Bound  Upper_Bound
  0   x2  NumericalContinuousParameter          6.0          9.0
              
  List of Linear Equality Constraints
  Empty DataFrame
  Columns: []
  Index: []
              
  List of Linear Inequality Constraints
  Empty DataFrame
  Columns: []
  Index: []
~~~

## Restricting Search Spaces Using Constraints

Most constructors for both subspaces and search spaces support the optional keyword argument `constraints` to provide a list of [`Constraint`](baybe.constraints.base.Constraint) objects. 
When constructing full search spaces, the type of each constraint is checked, and the consequently applied to the corresponding subspace.

~~~python
constraints = [...]
# Using one example constructor here
searchspace = SearchSpace.from_product(parameters=parameters, constraints=constraints)
~~~

For a more in-depth discussion of constraints, we refer to the corresponding  [user guide](../../userguide/constraints) for more details.