Repository: E-MAB/GMAB
Pull request #44: Interface for categorical parameters (see PR#43)
Author: login: fwuermseher
Labels: name: enhancement
description: New feature or request
name: python
description: Related to pygmab
Created: 2025-03-31T10:49:53Z | Merged: 2025-04-18T17:34:24Z

## Overview
This PR introdues a python API for categorical/ordinal parameters to close #43.

## Changes Made (CategoricalParam)
* Adds interface for the optimisation of categorical variables. Based on a list of user-specific choices, CategoricalParam enables gmab to determine the optimal choice as the best index within choices. The CategoricalParam class handles necessary conversion during and before optimization. 
* An ordinal scale is assumed for the list of choices - which means changing the order of choices can change the optimization results.

## Changes Made (Other Modules)
* Adds a simple clustering example for integration tests of the study module with different types of parameters.
* Fixes a bug in `study.optimize()`, where bounds where not retrieved correctly from >1 parameters.

Linked issue #43: Interface for categorical parameters
## Issue 

Gmab samples integer decision parameters using the specified bounds. The package should also offer interfaces for users that want to run an optimization that has categorical parameters.

## Suggested Changes

- Add an interface that simplifies the process for setting CategoricalParams on top of the existing API (Inherits from BaseParam).

## Expected Outcome

- The User can define a categorical parameter based on a [Sequence](https://docs.python.org/3/library/collections.abc.html) of choices.
- Using indexation and the length of the Sequence, CategoricalParam creates integer bounds for gmab and maps internal representation to the real values.

```python
import gmab
param = gmab.CategoricalParam(['a', 'b', 'c'])

param.bounds # Output: [(0, 2)]

param.map_to_value([0]) # Output: 'a'
```

Note that, similar to other libaries, this implements the Categorical Parameter on an *ordinal scale*, which means the simulation results could be affected by the input order for the choices.

(see [optuna's Categorical Distribution](https://github.com/optuna/optuna/blob/master/optuna/distributions.py#L483) or [sklearn's label encoder](https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.LabelEncoder.html))

Diff:
diff --git a/pygmab/python/gmab/__init__.py b/pygmab/python/gmab/__init__.py
index 645bdc9..1d917d6 100644
--- a/pygmab/python/gmab/__init__.py
+++ b/pygmab/python/gmab/__init__.py
@@ -1,6 +1,6 @@
 from gmab import logging
 from gmab.gmab import Gmab
-from gmab.params import FloatParam, IntParam
+from gmab.params import CategoricalParam, FloatParam, IntParam
 from gmab.search import GmabSearchCV
 from gmab.study import Study
 
@@ -9,6 +9,7 @@
     "GmabSearchCV",
     "logging",
     "Study",
-    "IntParam",
+    "CategoricalParam",
     "FloatParam",
+    "IntParam",
 ]
diff --git a/pygmab/python/gmab/params/__init__.py b/pygmab/python/gmab/params/__init__.py
index b604630..0c9d69e 100644
--- a/pygmab/python/gmab/params/__init__.py
+++ b/pygmab/python/gmab/params/__init__.py
@@ -1,5 +1,6 @@
 from gmab.params.base_param import BaseParam
+from gmab.params.categorical_param import CategoricalParam
 from gmab.params.float_param import FloatParam
 from gmab.params.int_param import IntParam
 
-__all__ = ["BaseParam", "IntParam", "FloatParam"]
+__all__ = ["BaseParam", "CategoricalParam", "IntParam", "FloatParam"]
diff --git a/pygmab/python/gmab/params/categorical_param.py b/pygmab/python/gmab/params/categorical_param.py
new file mode 100644
index 0000000..51cc33e
--- /dev/null
+++ b/pygmab/python/gmab/params/categorical_param.py
@@ -0,0 +1,75 @@
+from collections.abc import Callable
+from functools import cached_property
+
+from gmab.params.base_param import BaseParam
+
+ChoiceType = bool | int | float | str | Callable | None
+
+
+class CategoricalParam(BaseParam):
+    """
+    A class representing a categorical parameter.
+    """
+
+    def __init__(self, choices: list[ChoiceType]):
+        """
+        Creates a CategoricalParam that will suggest one of the choices during optimization.
+
+        Args:
+            choices (list[ChoiceType]): A list of possible choices for the parameter.
+
+        Returns:
+            CategoricalParam: An instance of the parameter with the specified properties.
+
+        Raises:
+            ValueError: Raises a ValueError if choices is not a list, or if the objects in the list
+            are not of an immutable or callable type (bool, int, float, str, Callable, or None).
+            For example, a list of dictionaries or lists would raise an error.
+
+        Example:
+        >>> param = CategoricalParam(choices=["a", "b", "c"])
+        >>> print(param)
+        CategoricalParam(["a", "b", "c"])
+
+        Note:
+            This parameter assumes an ordinal scale for the choices during optimization.
+        """
+        if not isinstance(choices, list):
+            raise ValueError("choices must be a list")
+        if not all(isinstance(c, ChoiceType) for c in choices):
+            raise ValueError("All elements in choices must be of an immutable or callable type")
+
+        super().__init__(size=1)
+        self.choices: list[ChoiceType] = choices
+
+    def __repr__(self):
+        return f"CategoricalParam(choices={self.choices})"
+
+    @cached_property
+    def bounds(self) -> list[tuple]:
+        """
+        Calculates and returns the parameter's internal bounds for optimization.
+
+        The bounds are used as constraints for the internal representation (or actions)
+        of the optimization algorithm regarding the parameter's value.
+
+        Returns:
+            list[tuple]: A list of tuples representing the bounds.
+        """
+        return [(0, len(self.choices) - 1)]
+
+    def map_to_value(self, actions: list[int]) -> ChoiceType | list[ChoiceType]:
+        """
+        Maps an action from the optimization problem to the value of the parameter.
+
+        Args:
+            actions (list[int]): A list of integers to map.
+
+        Returns:
+            ChoiceType | list[ChoiceType]: The resulting choice(s).
+        """
+        actions = [self.choices[idx] for idx in actions]
+
+        if len(actions) == 1:
+            return actions[0]
+        return actions
diff --git a/pygmab/python/gmab/study/study.py b/pygmab/python/gmab/study/study.py
index 7bb201d..e48b60e 100644
--- a/pygmab/python/gmab/study/study.py
+++ b/pygmab/python/gmab/study/study.py
@@ -95,7 +95,11 @@ def optimize(self, func: Callable, params: dict, trials: int) -> None:
         self.func = func  # ToDo: Add input validation
         self.params = params  # ToDo: Add input validation
 
-        bounds = next(param.bounds for param in self.params.values())
+        # Retrieve the bounds for the parameters
+        bounds = []
+        for param in self.params.values():
+            bounds.extend(param.bounds)
+
         gmab = self._algorithm(self._run_trial, bounds, self.seed)
         best_action_vector = gmab.optimize(trials)
 
diff --git a/pygmab/tests/_functions/clustering.py b/pygmab/tests/_functions/clustering.py
new file mode 100644
index 0000000..cc0421f
--- /dev/null
+++ b/pygmab/tests/_functions/clustering.py
@@ -0,0 +1,45 @@
+"""
+Objective function and useful parameters for a clustering problem,
+which serves as an example to demonstrate the use of various parameters types.
+
+The test example is based on an example from sklearn:
+Title: Comparison of the K-Means and MiniBatchKMeans clustering algorithms
+Source: https://scikit-learn.org/stable/auto_examples/cluster/plot_mini_batch_kmeans.html#sphx-glr-auto-examples-cluster-plot-mini-batch-kmeans-py
+Last accessed: 2025-04-18
+Version: 1.6.1
+"""
+
+import numpy as np
+from gmab import CategoricalParam, FloatParam, IntParam
+from sklearn.cluster import KMeans, MiniBatchKMeans
+from sklearn.datasets import make_blobs
+
+# Useful parameters
+PARAMS = {
+    "algorithm": CategoricalParam([KMeans, MiniBatchKMeans]),
+    "init": CategoricalParam(["k-means++", "random"]),
+    "n_clusters": IntParam(1, 10),
+    "tol": FloatParam(1e-4, 1e-2),
+}
+BOUNDS = [(0, 1), (0, 1), (1, 10), (0, 100)]
+
+
+# Generate sample data
+np.random.seed(0)
+
+_centers = [[1, 1], [-1, -1], [1, -1]]
+_n_clusters = len(_centers)
+_X, labels_true = make_blobs(n_samples=10000, centers=_centers, cluster_std=0.7)
+
+
+def function(algorithm, init, n_clusters, tol) -> float:
+    """Evaluate the inertia of the clustering that results from the given parameters."""
+    clusterer = algorithm(init=init, n_clusters=n_clusters, tol=tol, n_init=10)
+    clusterer.fit(_X)
+    return clusterer.inertia_
+
+
+if __name__ == "__main__":
+    # Example usage
+    result = function(KMeans, "k-means++", 3, 0.001)
+    print(f"Clustering inertia: {result}")
diff --git a/pygmab/tests/_functions/rosenbrock.py b/pygmab/tests/_functions/rosenbrock.py
index 36cb32d..ba1eb12 100644
--- a/pygmab/tests/_functions/rosenbrock.py
+++ b/pygmab/tests/_functions/rosenbrock.py
@@ -17,3 +17,9 @@ def function(number: list):
             for i in range(len(number) - 1)
         ]
     )
+
+
+if __name__ == "__main__":
+    # Example usage
+    result = function([1, 1])
+    print(f"Value of the rosenbrock function: {result}")
diff --git a/pygmab/tests/param_tests/test_categorical_param.py b/pygmab/tests/param_tests/test_categorical_param.py
new file mode 100644
index 0000000..5a15a61
--- /dev/null
+++ b/pygmab/tests/param_tests/test_categorical_param.py
@@ -0,0 +1,48 @@
+from contextlib import nullcontext
+
+import pytest
+from gmab.params import CategoricalParam
+
+
+def dummy_func():
+    pass
+
+
+@pytest.mark.parametrize(
+    "choices, exp_bounds, expectation",
+    [
+        [["a", "b"], [(0, 1)], nullcontext()],
+        [[True, False], [(0, 1)], nullcontext()],
+        [[1, 2], [(0, 1)], nullcontext()],
+        [[1.0, 2.0], [(0, 1)], nullcontext()],
+        [[dummy_func, dummy_func], [(0, 1)], nullcontext()],
+        [["a", False], [(0, 1)], nullcontext()],
+        [["a", "b", None], [(0, 2)], nullcontext()],
+        [{"a", "b"}, None, pytest.raises(ValueError)],
+        [[["a", "b"], "c"], None, pytest.raises(ValueError)],
+    ],
+    ids=[
+        "choice_str",
+        "choice_bool",
+        "choice_int",
+        "choice_float",
+        "choice_Callable",
+        "choice_mixed",
+        "choice_mixed2",
+        "fail_choices_type",
+        "fail_choices_content",
+    ],
+)
+def test_cat_param(choices, exp_bounds, expectation):
+    with expectation:
+        param = CategoricalParam(choices)
+
+        bounds = param.bounds
+        assert bounds == exp_bounds
+
+        # Check if the exact choices are recreated using the mapping
+        for idx in range(bounds[0][0], bounds[0][1] + 1):
+            value = param.map_to_value([idx])
+            exp_value = choices[idx]
+            assert value == exp_value
+            assert isinstance(value, type(exp_value))
diff --git a/pygmab/tests/study_tests/test_study.py b/pygmab/tests/study_tests/test_study.py
index 0ed8ccb..665eeb7 100644
--- a/pygmab/tests/study_tests/test_study.py
+++ b/pygmab/tests/study_tests/test_study.py
@@ -4,6 +4,7 @@
 import pytest
 from gmab.study import Study
 
+from tests._functions import clustering as cl
 from tests._functions import rosenbrock as rb
 
 # ToDo: Add tests for output formats and properties
@@ -39,21 +40,22 @@ def test_study_init(seed, kwargs, caplog):
 
 
 @pytest.mark.parametrize(
-    "func, params, trials, exp_bounds, exp_result, kwargs",
+    "func, params, trials, exp_bounds, kwargs",
     [
-        [rb.function, rb.PARAMS_2D, 100, rb.BOUNDS_2D, rb.RESULTS_2D, {}],
+        [rb.function, rb.PARAMS_2D, 1, rb.BOUNDS_2D, {}],
+        [cl.function, cl.PARAMS, 1, cl.BOUNDS, {}],
     ],
     ids=[
-        "base",
+        "try_rosenbrock",  # Simple case with one integer parameter
+        "try_clustering",  # Case with multiple parameters and various types
         # ToDo: Input validation: Fail if func is not callable
         # ToDo: Input validation: Fail if params is not valid
         # ToDo: Input validation: Fail if trials is not positive integer
     ],
 )
-def test_study_optimize(func, params, trials, exp_bounds, exp_result, kwargs):
+def test_study_optimize(func, params, trials, exp_bounds, kwargs):
     # Mock GMAB Algorithm
     mock = MagicMock()
-    mock.optimize.return_value = exp_result
 
     expectation = kwargs.pop("exp", nullcontext())
     with expectation:


Reviews:
user: login: fwuermseher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T14:52:12Z
user: login: fwuermseher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T14:52:45Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T07:29:13Z
user: login: fwuermseher
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T11:05:44Z
user: login: tnkuehne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T17:32:45Z
user: login: tnkuehne
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-18T17:34:02Z

Review comments:
diff_hunk: @@ -0,0 +1,74 @@
+from functools import cached_property
+
+from gmab.params.base_param import BaseParam
+
+ChoiceType = bool | int | float | str | None
+
+
+class CategoricalParam(BaseParam):
+    """
+    A class representing a categorical param.
+    """
+
+    def __init__(self, choices: list[ChoiceType]):
+        """
+        Creates a CategoricalParam that will suggest one the choices during optimization.
path: pygmab/python/gmab/params/categorical_param.py
user: login: fwuermseher
body: ToDo: Check spelling
created_at: 2025-04-16T14:52:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,31 @@
+from contextlib import nullcontext
+
+import pytest
+from gmab.params import CategoricalParam
+
+test_cat_param_data = [
+    pytest.param(["a", "b"], [(0, 1)], nullcontext(), id="choice_str"),
+    pytest.param([True, False], [(0, 1)], nullcontext(), id="choice_bool"),
+    pytest.param([1, 2], [(0, 1)], nullcontext(), id="choice_int"),
+    pytest.param([1.0, 2.0], [(0, 1)], nullcontext(), id="choice_float"),
+    pytest.param(["a", False], [(0, 1)], nullcontext(), id="choice_mixed"),
+    pytest.param(["a", None], [(0, 1)], nullcontext(), id="choice_mixed"),
path: pygmab/tests/param_tests/test_categorical_param.py
user: login: fwuermseher
body: ToDo: No Duplicate Test IDs
created_at: 2025-04-16T14:52:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,73 @@
+from functools import cached_property
+
+from gmab.params.base_param import BaseParam
+
+ChoiceType = bool | int | float | str | None
+
+
+class CategoricalParam(BaseParam):
+    """
+    A class representing a categorical parameter.
+    """
+
+    def __init__(self, choices: list[ChoiceType]):
+        """
+        Creates a CategoricalParam that will suggest one of the choices during optimization.
+
+        Args:
+            choices (list[ChoiceType]): A list of possible choices for the parameter.
+
+        Returns:
+            CategoricalParam: An instance of the parameter with the specified properties.
+
+        Raises:
+            ValueError: Raises a ValueError if choices is not a list, or if the objects in the list
+            are not of an immutable type (bool, int, float, str, or None).
+
+        Example:
+        >>> param = CategoricalParam(choices=["a", "b", "c"])
+        >>> print(param)
+        CategoricalParam(["a", "b", "c"])
+
+        Note:
+            This parameter assumes an ordinal scale for the choices during optimization.
+        """
+        if not isinstance(choices, list):
+            raise ValueError("choices must be a list")
+        if not all(isinstance(c, ChoiceType) for c in choices):
path: pygmab/python/gmab/params/categorical_param.py
user: login: Copilot
body: Using 'isinstance(c, ChoiceType)' is problematic because ChoiceType is defined as a union (bool | int | float | str | None) which is not a valid type argument for isinstance. Replace it with an explicit tuple of types like (bool, int, float, str, type(None)).
```suggestion
        if not all(isinstance(c, (bool, int, float, str, type(None))) for c in choices):
```
created_at: 2025-04-17T07:29:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+from collections.abc import Callable
+from functools import cached_property
+
+from gmab.params.base_param import BaseParam
+
+ChoiceType = bool | int | float | str | Callable | None
+
path: pygmab/python/gmab/params/categorical_param.py
user: login: fwuermseher
body: @tnkuehne 

The reason to limit the types to (bool, int, float, str, None) was to keep it simple and avoid problems associated with mutable types (list, dict, set, ...). Mainly, to avoid mutability of the choices during optimization, and guarantee predictable behaviour for the parameter class. 

Including Callable as well might be beneficial, for example for ML tuning use cases (different models, etc...). If we assume the function is well-behaved, which is up to the user, the package can treat it the same way like an immutable type. 

However, I'm not sure if we should include it. Whats is your opinion?
created_at: 2025-04-18T11:05:44Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,75 @@
+from collections.abc import Callable
+from functools import cached_property
+
+from gmab.params.base_param import BaseParam
+
+ChoiceType = bool | int | float | str | Callable | None
+
path: pygmab/python/gmab/params/categorical_param.py
user: login: tnkuehne
body: Yeah, sounds good. Probably needs some user testing to verify that this enables most use cases, but it's easily changeable, so I will merge.
created_at: 2025-04-18T17:32:45Z
author_association: MEMBER

PR comments:
user: login: tnkuehne
created_at: 2025-04-16T20:50:01Z
body: ```python
import gmab

def objective(categories: str) -> float:
    return ord(categories)

params = {
    "categories": gmab.CategoricalParam(choices=["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"])
}

study = gmab.Study()
study.optimize(objective, params, trials=100)
print(study.best_trial)
```

```console
thread '<unnamed>' panicked at /home/timo/ZedProjects/G-MAB/gmab/src/genetic.rs:70:35:
cannot sample empty range
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Traceback (most recent call last):
  File "/home/timo/ZedProjects/G-MAB/pygmab/../examples/testerer.py", line 11, in <module>
    study.optimize(objective, params, trials=100)
  File "/home/timo/ZedProjects/G-MAB/pygmab/python/gmab/study/study.py", line 100, in optimize
    best_action_vector = gmab.optimize(trials)
pyo3_runtime.PanicException: cannot sample empty range
```
author_association: MEMBER
user: login: fwuermseher
created_at: 2025-04-18T11:44:02Z
body: > ```python
> import gmab
> 
> def objective(categories: str) -> float:
>     return ord(categories)
> 
> params = {
>     "categories": gmab.CategoricalParam(choices=["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"])
> }
> 
> study = gmab.Study()
> study.optimize(objective, params, trials=100)
> print(study.best_trial)
> ```
> 
> ```
> thread '<unnamed>' panicked at /home/timo/ZedProjects/G-MAB/gmab/src/genetic.rs:70:35:
> cannot sample empty range
> note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
> Traceback (most recent call last):
>   File "/home/timo/ZedProjects/G-MAB/pygmab/../examples/testerer.py", line 11, in <module>
>     study.optimize(objective, params, trials=100)
>   File "/home/timo/ZedProjects/G-MAB/pygmab/python/gmab/study/study.py", line 100, in optimize
>     best_action_vector = gmab.optimize(trials)
> pyo3_runtime.PanicException: cannot sample empty range
> ```

I've investigated a bit, and despite some issues in the python implementation that still needed a fix 😶‍🌫️ , the root cause lies within the rust module. 

Rust panics, if bounds are one-dimensional, likebounds = [(0, 25)], as gmab.rs cannot sample from a one-dimensional list of bounds. This applies to all one-dimensional problems, regardless of entrypoint (Rust, python binding, python API).

Therefore, I've opened a new issue #53

----------

Other issues I've found and fixed on the way:
* Issue, where `choices=[]` lead to invalid bounds --> Fixed by raising an error.
* Issue, where `study.optimize()` did not generate the bounds for configurations with >1 parameter --> Fixed by changing how bounds are retrieved.

-----------

A dirty fix for illustration:

```python
if __name__ == "__main__":

    import gmab

    def objective(categories: str, dummy: int) -> float:
        return ord(categories)
    
    params = {
        "categories": gmab.CategoricalParam(choices=["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"]),
        "dummy": gmab.CategoricalParam([0]) # Bounds are now [(0, 25), (0, 0)]
    }
    study = gmab.Study()
    study.optimize(objective, params, trials=100)
    print(study.best_trial)

```
```
(.venv) PS C:\Users\fwuer\Code\GMAB\pygmab> & c:/Users/fwuer/Code/GMAB/.venv/Scripts/python.exe c:/Users/fwuer/Code/GMAB/pygmab/python/gmab/study/study.py
No seed provided. Results will not be reproducible.
{'categories': 'A', 'dummy': 0}
```
author_association: COLLABORATOR