# 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`.

(DATA_REPRESENTATION)=
### 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
~~~

## 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 continuous parameter representations when identifying parameter ranges based only on data, this function requires that the appropriate parameter definitions be explicitly provided. This is different for its subspace counterparts [`SubspaceDiscrete.from_dataframe`](baybe.searchspace.discrete.SubspaceDiscrete.from_dataframe) and [`SubspaceContinuous.from_dataframe`](baybe.searchspace.continuous.SubspaceContinuous.from_dataframe), where a fallback mechanism can automatically infer minimal parameter specifications if omitted.

```python
from baybe.searchspace import SearchSpace

p_cont = NumericalContinuousParameter(name="c", bounds=[0, 1])
p_disc = NumericalDiscreteParameter(name="d", values=[1, 2, 3])
df = pd.DataFrame({"c": [0.3, 0.7], "d": [2, 3]})
searchspace = SearchSpace.from_dataframe(df=df, parameters=[p_cont, p_disc])
print(searchspace)
```

~~~
SearchSpace
   Search Space Type: HYBRID
   SubspaceDiscrete
      Discrete Parameters
           Name                        Type  Num_Values Encoding
         0    d  NumericalDiscreteParameter           3     None
      Experimental Representation
            d
         0  2
         1  3
      Constraints
         Empty DataFrame
         Columns: []
         Index: []
      Computational Representation
            d
         0  2
         1  3
   SubspaceContinuous
      Continuous Parameters
           Name                          Type  Lower_Bound  Upper_Bound
         0    c  NumericalContinuousParameter          0.0          1.0
      Linear Equality Constraints
         Empty DataFrame
         Columns: []
         Index: []
      Linear Inequality Constraints
         Empty DataFrame
         Columns: []
         Index: []
      Non-linear 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.