Repository: optuna/optuna
Pull request: Add test for casting in `test_nsgaii.py`
## Motivation
Resolve #3982
## Description of the changes
Added test to confirm that type of categorical variable is not unexpectedly casted in `NSGAIISampler`.
## Alternatives
Test `_crossover.perform_crossover` or `_crossover._try_crossover` directly.


Problem statement:
unrobust optuna sampling behaviour
### Expected behavior

I have something like this in the code: `trial.suggest_categorical('somename', [False, True])`

and when it is running sometimes it gives an error like `ValueError: 'True' not in (False, True).`, but sometimes it samples just fine without this kind of error.

I looked into issue with pdb and at `optuna/samplers/nsgaii/_crossover.py(115)` you actually have a `child_params` dictionary which contains  sampled parameters and there is `{..., 'somename': "False"}`, although it would make much better sense to have that parameter sampled in `boolean` values.

Any ideas why that would be happening? Thank you in advance.

### Environment

- Optuna version: 3.0.1
- Python version: 3.8.13
- OS:Linux-4.15.0-1051-aws-x86_64-with-glibc2.17
- (Optional) Other libraries and their versions:


### Error messages, stack traces, or logs

```shell
File "/lib/python3.8/site-packages/optuna/study/study.py", line 419, in optimize
    _optimize(
  File "/lib/python3.8/site-packages/optuna/study/_optimize.py", line 66, in _optimize
    _optimize_sequential(
  File "/lib/python3.8/site-packages/optuna/study/_optimize.py", line 160, in _optimize_sequential
    frozen_trial = _run_trial(study, func, catch)
  File "/lib/python3.8/site-packages/optuna/study/_optimize.py", line 187, in _run_trial
    trial = study.ask()
  File "/lib/python3.8/site-packages/optuna/study/study.py", line 514, in ask
    trial = trial_module.Trial(self, trial_id)
  File "/lib/python3.8/site-packages/optuna/trial/_trial.py", line 54, in __init__
    self._init_relative_params()
  File "/lib/python3.8/site-packages/optuna/trial/_trial.py", line 63, in _init_relative_params
    self.relative_params = self.study.sampler.sample_relative(
  File "/lib/python3.8/site-packages/optuna/samplers/nsgaii/_sampler.py", line 211, in sample_relative
    child_params = perform_crossover(
  File "/lib/python3.8/site-packages/optuna/samplers/nsgaii/_crossover.py", line 115, in perform_crossover
    if _is_contained(child_params, search_space):
  File "/lib/python3.8/site-packages/optuna/samplers/nsgaii/_crossover.py", line 160, in _is_contained
    if not param_distribution._contains(param_distribution.to_internal_repr(param)):
  File "/lib/python3.8/site-packages/optuna/distributions.py", line 516, in to_internal_repr
    raise ValueError(f"'{param_value_in_external_repr}' not in {self.choices}.")
ValueError: 'True' not in (False, True).
```


### Steps to reproduce

1.
2.
3.
```python
# python code
```


### Additional context (optional)

_No response_

Hints from issue discussion:
Hi, could you share the minimal reproducible code? 
I couldn't see any error when I ran the following code:

```python
import optuna

def objective1(trial):
    for i in range(10):
        trial.suggest_categorical(f"x_{i}", [False, True])
    return trial.suggest_float("y", 0, 1)


for seed in range(100):
    study = optuna.create_study(sampler=optuna.samplers.NSGAIISampler(seed=seed))
    study.optimize(objective1, n_trials=30)

```
This issue has not seen any recent activity.
I encountered the same issue as well with version 3.0.1. 

The bug seems to be deterministic. Conditions are
- Trial has string and non-string categorical parameters.
- n_trials > population_size (i.e. when child parameters are generated)

Here is minimum reproducible code.

```python
import optuna
from random import random

def objective1(trial):
    # This doesn't fail
    # cat_1 = trial.suggest_categorical(f"cat_1", [10, 12]) 
    # cat_2 = trial.suggest_categorical(f"cat_2", [1, 2, 3])

    # This doesn't fail
    # cat_1 = trial.suggest_categorical(f"cat_1", ['para1', 'para2']) 
    # cat_2 = trial.suggest_categorical(f"cat_2", ['1', '2', '3'])

    # This doesn't fail
    # cat_1 = trial.suggest_categorical(f"cat_1", [True, False])
    # cat_2 = trial.suggest_categorical(f"cat_2", [1, 2, 3])

    # This fails
    cat_1 = trial.suggest_categorical(f"cat_1", ['string1', 'string2'])
    cat_2 = trial.suggest_categorical(f"cat_2", [1, 2, 3])

    # This fails
    # cat_1 = trial.suggest_categorical(f"cat_1", ['string1', 'string2'])
    # cat_2 = trial.suggest_categorical(f"cat_2", [True, False])

    return random()

population_size = 50

study = optuna.create_study(sampler=optuna.samplers.NSGAIISampler(population_size=population_size))

study.optimize(objective1, n_trials=population_size-1) # This doesn't fail
study.optimize(objective1, n_trials=population_size+1) # This fails if trial has string and non-string categorical parameters

```


@skotaro Brilliant. Thank you for sharing the minimal code to get the error.
Thank you for sharing the code! I found a bug here. We should specify `dtype=object` to prevent unexpected casting.
https://github.com/optuna/optuna/blob/a7cd842f2aca675e517fc7bf74f22762380aeb24/optuna/samplers/nsgaii/_crossover.py#L40-L45
The bug has been fixed. I will close this issue after adding a test.

Commit message: Merge branch 'optuna:master' into add-crossover-test

Patch to review:
diff --git a/tests/samplers_tests/test_nsgaii.py b/tests/samplers_tests/test_nsgaii.py
index e32d344288a..ab2d426cf34 100644
--- a/tests/samplers_tests/test_nsgaii.py
+++ b/tests/samplers_tests/test_nsgaii.py
@@ -115,6 +115,22 @@ def test_swapping_prob() -> None:
         NSGAIISampler(swapping_prob=1.1)
 
 
+@pytest.mark.parametrize("categorical_values1", [[1, 2, 3], [True, False], ["True", "False"]])
+@pytest.mark.parametrize("categorical_values2", [[10, 12], ["-2", "-1"]])
+def test_crossover_casting(categorical_values1: List[Any], categorical_values2: List[Any]) -> None:
+    def objective(trial: optuna.Trial) -> Sequence[float]:
+        cat_1 = trial.suggest_categorical("cat_1", categorical_values1)
+        cat_2 = trial.suggest_categorical("cat_2", categorical_values2)
+        assert isinstance(cat_1, type(categorical_values1[0]))
+        assert isinstance(cat_2, type(categorical_values2[0]))
+        return 1.0, 2.0
+
+    population_size = 10
+    sampler = NSGAIISampler(population_size=population_size)
+    study = optuna.create_study(directions=["minimize"] * 2, sampler=sampler)
+    study.optimize(objective, n_trials=population_size * 2)
+
+
 def test_constraints_func_none() -> None:
     n_trials = 4
     n_objectives = 2


Code review comments:
diff_hunk: @@ -115,6 +115,22 @@ def test_swapping_prob() -> None:
         NSGAIISampler(swapping_prob=1.1)
 
 
+@pytest.mark.parametrize("categorical_values1", [[1, 2, 3], [True, False], ["True", "False"]])
+@pytest.mark.parametrize("categorical_values2", [[10, 12], ["-2", "-1"]])
path: tests/samplers_tests/test_nsgaii.py
text: @user1:
I'm not sure, but do we need to check the combination of `categorical_values1` and `categorical_values2`?
If multiple parameters are required to activate joint sampling instead of independent sampling, we may be able to use the same categorical values for both `cat_1` and `cat_2`.

@author:
Thank you for your indication.
I think using two different types of categorical_values was essential for the error (https://github.com/optuna/optuna/issues/3982#issuecomment-1263194047), but I agree that it will be more simple to write test cases without using combination. I fixed it accordingly.

@user1:
Hmm, the new version of the test function has more lines than before.

> I think using two different types of categorical_values was essential for the error (https://github.com/optuna/optuna/issues/3982#issuecomment-1263194047)

The minimum requirement of the test case seems to be a combination of raw values and stringified values.
If so, how about just parametrizing raw values? I guess stringified values can be generated in the test case.
The following is just an example.

```python
@user2.mark.parametrize("choices", [[-1, 0, 1], [True, False], ["string1", "string2"]])
def test_crossover_casting(choices: List[Any]) -> None:
    def objective(trial: optuna.Trial) -> Sequence[float]:
        str_choices = list(map(str, choices))
        r = trial.suggest_categorical("r", choices)
        s = trial.suggest_categorical("s", str_choices)
        assert isinstance(r, type(choices[0]))
        assert isinstance(s, type(str_choices[0]))
        return 1.0, 2.0
```

This function can detect the bug as follows:
```console
% pytest tests/samplers_tests/test_nsgaii.py::test_crossover_casting
...
FAILED tests/samplers_tests/test_nsgaii.py::test_crossover_casting[choices0] - ValueError: '-1' not in (-1, 0, 1).
FAILED tests/samplers_tests/test_nsgaii.py::test_crossover_casting[choices1] - ValueError: 'True' not in (True, False).
```


This test case lacks the pair of numbers and bools, but I don't think it is critical because the bug just requires two different types of values (e.g., numbers and strings, or bools and strings).

@user1:
Thank you for applying the suggestion to the PR.
@user3 What do you think of this simplification?

@user3:
Looks very good.