Repository: optuna/optuna
Pull request: Make `GridSampler` reproducible
<!-- Thank you for creating a pull request! In general, we merge your pull requests after they get two or more approvals. -->

## Motivation
https://github.com/optuna/optuna/issues/3516
<!-- Describe your motivation why you will submit this PR. This is useful for reviewers to understand the context of PR. -->

## Description of the changes
- `GridSampler` can receive a seed for its random number generator `_rng`
- add `reseed_rng` function like `TPESampler` and `RandomSampler`
- add tests for a fixed seed and `reseed_rng`

To check its behavior, I used the code below.
``` python
import optuna


def objective(trial):
    a = trial.suggest_int("a", 0, 5)
    return a + trial.number


for i in range(3):
    sampler = optuna.samplers.GridSampler({"a": [0, 1, 2, 3, 4, 5]}, 1)  # seed: 1
    study = optuna.create_study(sampler=sampler)
    study.optimize(objective)

    if i == 2:
        sampler.reseed_rng() # seed is randomized
        study = optuna.create_study(sampler=sampler)
        study.optimize(objective) # maybe different behavior

```

Problem statement:
`GridSampler` is not reproducible
### Expected behavior

`GridSampler` does not have a `seed` argument, but it randomly shuffles an evaluation order of trials. It makes optimization results unreproducible if objective functions depend on previous trials, e.g., pruning. We should add a `seed` argument like `TPESampler` and `RandomSampler`.

### Environment

- Optuna version: 3.0.0b1.dev
- Python version: 3.8.6
- OS: macOS-10.16-x86_64-i386-64bit
- (Optional) Other libraries and their versions:


### Error messages, stack traces, or logs

```shell
See steps to reproduce.
```


### Steps to reproduce

```python
import optuna

def objective(trial):
    a = trial.suggest_int("a", 0, 2)
    return a + trial.number

sampler = optuna.samplers.GridSampler({"a": [0, 1, 2]})
study = optuna.create_study(sampler=sampler)
study.optimize(objective)
```


### Additional context (optional)

_No response_

Hints from issue discussion:

Commit message: revise comments

Patch to review:
diff --git a/optuna/samplers/_grid.py b/optuna/samplers/_grid.py
index 4673185dba8..18cfd635709 100644
--- a/optuna/samplers/_grid.py
+++ b/optuna/samplers/_grid.py
@@ -1,6 +1,5 @@
 import collections
 import itertools
-import random
 from typing import Any
 from typing import cast
 from typing import Dict
@@ -11,6 +10,8 @@
 from typing import Union
 import warnings
 
+import numpy as np
+
 from optuna.distributions import BaseDistribution
 from optuna.logging import get_logger
 from optuna.samplers import BaseSampler
@@ -97,9 +98,12 @@ def objective(trial):
         search_space:
             A dictionary whose key and value are a parameter name and the corresponding candidates
             of values, respectively.
+        seed: A seed to specify the order of trials as the grid is randomly shuffled.
     """
 
-    def __init__(self, search_space: Mapping[str, Sequence[GridValueType]]) -> None:
+    def __init__(
+        self, search_space: Mapping[str, Sequence[GridValueType]], seed: Optional[int] = None
+    ) -> None:
 
         for param_name, param_values in search_space.items():
             for value in param_values:
@@ -114,6 +118,11 @@ def __init__(self, search_space: Mapping[str, Sequence[GridValueType]]) -> None:
         self._all_grids = list(itertools.product(*self._search_space.values()))
         self._param_names = sorted(search_space.keys())
         self._n_min_trials = len(self._all_grids)
+        self._rng = np.random.RandomState(seed)
+
+    def reseed_rng(self) -> None:
+
+        self._rng = np.random.RandomState()
 
     def infer_relative_search_space(
         self, study: Study, trial: FrozenTrial
@@ -152,7 +161,7 @@ def sample_relative(
 
         # In distributed optimization, multiple workers may simultaneously pick up the same grid.
         # To make the conflict less frequent, the grid is chosen randomly.
-        grid_id = random.choice(target_grids)
+        grid_id = self._rng.choice(target_grids)
 
         study._storage.set_trial_system_attr(trial._trial_id, "search_space", self._search_space)
         study._storage.set_trial_system_attr(trial._trial_id, "grid_id", grid_id)
diff --git a/tests/samplers_tests/test_grid.py b/tests/samplers_tests/test_grid.py
index 4665989956a..88d20c7a0da 100644
--- a/tests/samplers_tests/test_grid.py
+++ b/tests/samplers_tests/test_grid.py
@@ -197,6 +197,28 @@ def test_enqueued_trial() -> None:
     assert sorted([study.trials[1].params["a"], study.trials[2].params["a"]]) == [0, 50]
 
 
+def test_same_seed_trial() -> None:
+    sampler1 = samplers.GridSampler({"a": [0, 20, 40, 60, 80, 100]}, 0)
+    study1 = optuna.create_study(sampler=sampler1)
+    study1.optimize(lambda trial: trial.suggest_int("a", 0, 100))
+
+    sampler2 = samplers.GridSampler({"a": [0, 20, 40, 60, 80, 100]}, 0)
+    study2 = optuna.create_study(sampler=sampler2)
+    study2.optimize(lambda trial: trial.suggest_int("a", 0, 100))
+
+    for i in range(6):
+        assert study1.trials[i].params["a"] == study2.trials[i].params["a"]
+
+
+def test_reseed_rng() -> None:
+    sampler = samplers.GridSampler({"a": [0, 100]})
+    sampler.reseed_rng()
+    study = optuna.create_study(sampler=sampler)
+    study.optimize(lambda trial: trial.suggest_int("a", 0, 100))
+
+    assert sorted([study.trials[0].params["a"], study.trials[1].params["a"]]) == [0, 100]
+
+
 def test_enqueued_insufficient_trial() -> None:
     sampler = samplers.GridSampler({"a": [0, 50]})
     study = optuna.create_study(sampler=sampler)


Code review comments:
diff_hunk: @@ -197,6 +197,28 @@ def test_enqueued_trial() -> None:
     assert sorted([study.trials[1].params["a"], study.trials[2].params["a"]]) == [0, 50]
 
 
+def test_same_seed_trial() -> None:
path: tests/samplers_tests/test_grid.py
text: @user1:
This is a really minor comment. SInce we have two trials, `trials` sound more accurate.
```suggestion
def test_same_seed_trials() -> None:
```

@user1:
For readability, how about explicitly declaring `seed`? I also prefer not to repeat the same search space twice. So how about the following code?

```python
    grid_values = [0, 20, 40, 60, 80, 100]
    seed = 0

    sampler1 = samplers.GridSampler({"a": grid_values}, seed)
    study1 = optuna.create_study(sampler=sampler1)
    study1.optimize(lambda trial: trial.suggest_int("a", 0, 100))

    sampler2 = samplers.GridSampler({"a": grid_values}, seed)
    study2 = optuna.create_study(sampler=sampler2)
    study2.optimize(lambda trial: trial.suggest_int("a", 0, 100))

    for i in range(len(grid_values)):
        assert study1.trials[i].params["a"] == study2.trials[i].params["a"]
```


@author:
Thank you for the comments.
It seems better and it is fixed ([9c34f97](https://github.com/optuna/optuna/pull/3527/commits/9c34f97c511944e32341745c06b3e5605f24991c)).

@user1:
thanks!