Repository: optuna/optuna

Issue:
Broken dependency for `BoTorchSampler`
### Expected behavior

No error when using `BoTorchSampler` with latest `botorch`.

### Environment

- Optuna version: v3.1.0
- Python version: 3.9
- OS: linux
- (Optional) Other libraries and their versions: botorch 0.8.1


### Error messages, stack traces, or logs

```shell
/tmp/ipykernel_105635/1131768219.py:1: ExperimentalWarning: BoTorchSampler is experimental (supported from v2.4.0). The interface can change in the future.
  study = optuna.create_study(sampler=BoTorchSampler())
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
File /usr/local/lib/python3.9/site-packages/optuna/integration/botorch.py:39
     38 from botorch.optim import optimize_acqf
---> 39 from botorch.sampling.samplers import SobolQMCNormalSampler
     40 from botorch.utils.multi_objective.box_decompositions import NondominatedPartitioning

ModuleNotFoundError: No module named 'botorch.sampling.samplers'

The above exception was the direct cause of the following exception:

ImportError                               Traceback (most recent call last)
Cell In[3], line 1
----> 1 study = optuna.create_study(sampler=BoTorchSampler())

File /usr/local/lib/python3.9/site-packages/optuna/_experimental.py:115, in experimental_class.<locals>.decorator.<locals>.wrapper.<locals>.wrapped_init(self, *args, **kwargs)
    104 @functools.wraps(_original_init)
    105 def wrapped_init(self: Any, *args: Any, **kwargs: Any) -> None:
    106     warnings.warn(
    107         "{} is experimental (supported from v{}). "
    108         "The interface can change in the future.".format(
   (...)
    112         stacklevel=2,
    113     )
--> 115     _original_init(self, *args, **kwargs)

File /usr/local/lib/python3.9/site-packages/optuna/integration/botorch.py:410, in BoTorchSampler.__init__(self, candidates_func, constraints_func, n_startup_trials, independent_sampler, seed)
    391 def __init__(
    392     self,
    393     *,
   (...)
    408     seed: Optional[int] = None,
    409 ):
--> 410     _imports.check()
    412     self._candidates_func = candidates_func
    413     self._constraints_func = constraints_func

File /usr/local/lib/python3.9/site-packages/optuna/_imports.py:89, in _DeferredImportExceptionContextManager.check(self)
     87 if self._deferred is not None:
     88     exc_value, message = self._deferred
---> 89     raise ImportError(message) from exc_value

ImportError: Tried to import 'botorch.sampling.samplers' but failed. Please make sure that the package is installed correctly to use this feature. Actual error: No module named 'botorch.sampling.samplers'.
```


### Steps to reproduce

1. `pip3 install optuna botorch`
2. 
```python
import optuna
from optuna.integration.botorch import BoTorchSampler
study = optuna.create_study(sampler=BoTorchSampler())
```


### Additional context (optional)

Not sure why CI doesn't fail.


Discussion:
https://github.com/optuna/optuna/blob/22e57eba9cf1f352800b99d8d6c8a23c47bad239/setup.py#L88
CI doesn't fail because the `botorch` version is set for `<0.8.0`. But, of course, it should work for the latest `botorch`, as there is no explicit documentation that an old version of `botorch` must be used.
Also, [the benchmark](https://github.com/optuna/optuna/blob/22e57eba9cf1f352800b99d8d6c8a23c47bad239/setup.py#L48) uses latest botorch, so it should fail.

Resolving patch:
diff --git a/optuna/integration/botorch.py b/optuna/integration/botorch.py
--- a/optuna/integration/botorch.py
+++ b/optuna/integration/botorch.py
@@ -32,11 +32,24 @@
     from botorch.acquisition.multi_objective.objective import IdentityMCMultiOutputObjective
     from botorch.acquisition.objective import ConstrainedMCObjective
     from botorch.acquisition.objective import GenericMCObjective
-    from botorch.fit import fit_gpytorch_model
     from botorch.models import SingleTaskGP
     from botorch.models.transforms.outcome import Standardize
     from botorch.optim import optimize_acqf
-    from botorch.sampling.samplers import SobolQMCNormalSampler
+    from botorch.sampling import SobolQMCNormalSampler
+    import botorch.version
+
+    if botorch.version.version_tuple < (0, 8, 0):
+        from botorch.fit import fit_gpytorch_model as fit_gpytorch_mll
+
+        def _get_sobol_qmc_normal_sampler(num_samples: int) -> SobolQMCNormalSampler:
+            return SobolQMCNormalSampler(num_samples)
+
+    else:
+        from botorch.fit import fit_gpytorch_mll
+
+        def _get_sobol_qmc_normal_sampler(num_samples: int) -> SobolQMCNormalSampler:
+            return SobolQMCNormalSampler(torch.Size((num_samples,)))
+
     from botorch.utils.multi_objective.box_decompositions import NondominatedPartitioning
     from botorch.utils.multi_objective.scalarization import get_chebyshev_scalarization
     from botorch.utils.sampling import manual_seed
@@ -124,12 +137,12 @@ def qei_candidates_func(
 
     model = SingleTaskGP(train_x, train_y, outcome_transform=Standardize(m=train_y.size(-1)))
     mll = ExactMarginalLogLikelihood(model.likelihood, model)
-    fit_gpytorch_model(mll)
+    fit_gpytorch_mll(mll)
 
     acqf = qExpectedImprovement(
         model=model,
         best_f=best_f,
-        sampler=SobolQMCNormalSampler(num_samples=256),
+        sampler=_get_sobol_qmc_normal_sampler(256),
         objective=objective,
     )
 
@@ -194,7 +207,7 @@ def qehvi_candidates_func(
 
     model = SingleTaskGP(train_x, train_y, outcome_transform=Standardize(m=train_y.shape[-1]))
     mll = ExactMarginalLogLikelihood(model.likelihood, model)
-    fit_gpytorch_model(mll)
+    fit_gpytorch_mll(mll)
 
     # Approximate box decomposition similar to Ax when the number of objectives is large.
     # https://github.com/facebook/Ax/blob/master/ax/models/torch/botorch_moo_defaults
@@ -213,10 +226,9 @@ def qehvi_candidates_func(
         model=model,
         ref_point=ref_point_list,
         partitioning=partitioning,
-        sampler=SobolQMCNormalSampler(num_samples=256),
+        sampler=_get_sobol_qmc_normal_sampler(256),
         **additional_qehvi_kwargs,
     )
-
     standard_bounds = torch.zeros_like(bounds)
     standard_bounds[1] = 1
 
@@ -274,7 +286,7 @@ def qnehvi_candidates_func(
 
     model = SingleTaskGP(train_x, train_y, outcome_transform=Standardize(m=train_y.shape[-1]))
     mll = ExactMarginalLogLikelihood(model.likelihood, model)
-    fit_gpytorch_model(mll)
+    fit_gpytorch_mll(mll)
 
     # Approximate box decomposition similar to Ax when the number of objectives is large.
     # https://github.com/facebook/Ax/blob/master/ax/models/torch/botorch_moo_defaults
@@ -295,7 +307,7 @@ def qnehvi_candidates_func(
         X_baseline=train_x,
         alpha=alpha,
         prune_baseline=True,
-        sampler=SobolQMCNormalSampler(num_samples=256),
+        sampler=_get_sobol_qmc_normal_sampler(256),
         **additional_qnehvi_kwargs,
     )
 
@@ -357,12 +369,12 @@ def qparego_candidates_func(
 
     model = SingleTaskGP(train_x, train_y, outcome_transform=Standardize(m=train_y.size(-1)))
     mll = ExactMarginalLogLikelihood(model.likelihood, model)
-    fit_gpytorch_model(mll)
+    fit_gpytorch_mll(mll)
 
     acqf = qExpectedImprovement(
         model=model,
         best_f=objective(train_y).max(),
-        sampler=SobolQMCNormalSampler(num_samples=256),
+        sampler=_get_sobol_qmc_normal_sampler(256),
         objective=objective,
     )
 


Test patch: