Skip to content

Commit 9632402

Browse files
authored
Merge pull request #310 from nabenabe0928/add-constraints-to-var
Add constrained VaR
2 parents 604f481 + 99103d5 commit 9632402

File tree

5 files changed

+261
-129
lines changed

5 files changed

+261
-129
lines changed

package/samplers/value_at_risk/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,17 @@ To implement this sampler, the author referred to [this paper](https://arxiv.org
1717

1818
## APIs
1919

20-
- `RobustGPSampler(*, seed: int | None = None, independent_sampler: BaseSampler | None = None, n_startup_trials: int = 10, deterministic_objective: bool = False, constraints_func: Callable[[FrozenTrial], Sequence[float]] | None = None, warn_independent_sampling: bool = True, uniform_input_noise_ranges: dict[str, float] | None = None, normal_input_noise_stdevs: dict[str, float] | None = None)`
20+
- `RobustGPSampler(*, seed: int | None = None, independent_sampler: BaseSampler | None = None, n_startup_trials: int = 10, deterministic_objective: bool = False, constraints_func: Callable[[FrozenTrial], Sequence[float]] | None = None, warn_independent_sampling: bool = True, uniform_input_noise_rads: dict[str, float] | None = None, normal_input_noise_stdevs: dict[str, float] | None = None)`
2121
- `seed`: Random seed to initialize internal random number generator. Defaults to `None` (a seed is picked randomly).
2222
- `independent_sampler`: Sampler used for initial sampling (for the first `n_startup_trials` trials) and for conditional parameters. Defaults to :obj:`None` (a random sampler with the same `seed` is used).
2323
- `n_startup_trials`: Number of initial trials. Defaults to 10.
2424
- `deterministic_objective`: Whether the objective function is deterministic or not. If `True`, the sampler will fix the noise variance of the surrogate model to the minimum value (slightly above 0 to ensure numerical stability). Defaults to `False`. Currently, all the objectives will be assume to be deterministic if `True`.
2525
- `constraints_func`: An optional function that computes the objective constraints. It must take a `optuna.trial.FrozenTrial` and return the constraints. The return value must be a sequence of `float`. A value strictly larger than 0 means that a constraint is violated. A value equal to or smaller than 0 is considered feasible. If `constraints_func` returns more than one value for a trial, that trial is considered feasible if and only if all values are equal to 0 or smaller. The `constraints_func` will be evaluated after each successful trial. The function won't be called when trials fail or are pruned, but this behavior is subject to change in future releases.
2626
- `warn_independent_sampling`: If this is `True`, a warning message is emitted when the value of a parameter is sampled by using an independent sampler, meaning that no GP model is used in the sampling. Note that the parameters of the first trial in a study are always sampled via an independent sampler, so no warning messages are emitted in this case.
27-
- `uniform_input_noise_ranges`: The input noise ranges for each parameter. For example, when `{"x": 0.1, "y": 0.2}`, the sampler assumes that $\\pm$ 0.1 is acceptable for `x` and $\\pm$ 0.2 is acceptable for `y`.
27+
- `uniform_input_noise_rads`: The input noise radiuses for each parameter. For example, when `{"x": 0.1, "y": 0.2}`, the sampler assumes that $\\pm$ 0.1 is acceptable for `x` and $\\pm$ 0.2 is acceptable for `y`.
2828
- `normal_input_noise_stdevs`: The input noise standard deviations for each parameter. For example, when `{"x": 0.1, "y": 0.2}` is given, the sampler assumes that the input noise of `x` and `y` follows `N(0, 0.1**2)` and `N(0, 0.2**2)`, respectively.
2929

30-
Please note that only one of `uniform_input_noise_ranges` and `normal_input_noise_stdevs` can be provided.
30+
Please note that only one of `uniform_input_noise_rads` and `normal_input_noise_stdevs` can be provided.
3131

3232
## Installation
3333

@@ -74,7 +74,7 @@ def objective(trial: optuna.Trial) -> float:
7474

7575

7676
RobustGPSampler = optunahub.load_module("samplers/value_at_risk").RobustGPSampler
77-
sampler = RobustGPSampler(seed=0, uniform_input_noise_ranges={"x": 0.5, "y": 0.5})
77+
sampler = RobustGPSampler(seed=0, uniform_input_noise_rads={"x": 0.5, "y": 0.5})
7878
study = optuna.create_study(sampler=sampler)
7979
study.optimize(objective, n_trials=50)
8080

package/samplers/value_at_risk/_gp/acqf.py

Lines changed: 151 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,39 @@ def _sample_from_normal_sobol(dim: int, n_samples: int, seed: int | None) -> tor
4848
return torch.erfinv(samples) * float(np.sqrt(2))
4949

5050

51+
def _sample_input_noise(
52+
n_input_noise_samples: int,
53+
uniform_input_noise_rads: torch.Tensor | None,
54+
normal_input_noise_stdevs: torch.Tensor | None,
55+
seed: int | None,
56+
) -> torch.Tensor:
57+
assert uniform_input_noise_rads is not None or normal_input_noise_stdevs is not None
58+
if normal_input_noise_stdevs is not None:
59+
dim = normal_input_noise_stdevs.size(0)
60+
noisy_inds = torch.where(normal_input_noise_stdevs != 0.0)
61+
input_noise = torch.zeros(size=(n_input_noise_samples, dim), dtype=torch.float64)
62+
input_noise[:, noisy_inds[0]] = (
63+
_sample_from_normal_sobol(noisy_inds[0].size(0), n_input_noise_samples, seed)
64+
* normal_input_noise_stdevs[noisy_inds]
65+
)
66+
return input_noise
67+
elif uniform_input_noise_rads is not None:
68+
dim = uniform_input_noise_rads.size(0)
69+
noisy_inds = torch.where(uniform_input_noise_rads != 0.0)
70+
input_noise = torch.zeros(size=(n_input_noise_samples, dim), dtype=torch.float64)
71+
input_noise[:, noisy_inds[0]] = (
72+
_sample_from_sobol(noisy_inds[0].size(0), n_input_noise_samples, seed)
73+
* 2
74+
* uniform_input_noise_rads[noisy_inds]
75+
- uniform_input_noise_rads[noisy_inds]
76+
)
77+
return input_noise
78+
else:
79+
raise ValueError(
80+
"Either `uniform_input_noise_rads` or `normal_input_noise_stdevs` " "must be provided."
81+
)
82+
83+
5184
class BaseAcquisitionFunc(ABC):
5285
def __init__(self, length_scales: np.ndarray, search_space: SearchSpace) -> None:
5386
self.length_scales = length_scales
@@ -69,69 +102,99 @@ def eval_acqf_with_grad(self, x: np.ndarray) -> tuple[float, np.ndarray]:
69102
return val.item(), x_tensor.grad.detach().numpy() # type: ignore
70103

71104

105+
class LogCumulativeProbabilityAtRisk(BaseAcquisitionFunc):
106+
"""The logarithm of the cumulative probability measure at risk
107+
108+
When we replace f(x) in VaR with 1[f(x) <= f*], the optimization of the new VaR corresponds to
109+
that of the mean probability of x with input perturbation being feasible.
110+
"""
111+
112+
def __init__(
113+
self,
114+
gpr_list: list[GPRegressor],
115+
search_space: SearchSpace,
116+
confidence_level: float,
117+
threshold_list: list[float],
118+
n_input_noise_samples: int,
119+
qmc_seed: int | None,
120+
uniform_input_noise_rads: torch.Tensor | None = None,
121+
normal_input_noise_stdevs: torch.Tensor | None = None,
122+
stabilizing_noise: float = 1e-12,
123+
) -> None:
124+
self._gpr_list = gpr_list
125+
self._threshold_list = threshold_list
126+
rng = np.random.RandomState(qmc_seed)
127+
self._input_noise = _sample_input_noise(
128+
n_input_noise_samples,
129+
uniform_input_noise_rads,
130+
normal_input_noise_stdevs,
131+
seed=rng.random_integers(0, 2**31 - 1, size=1).item(),
132+
)
133+
self._stabilizing_noise = stabilizing_noise
134+
self._confidence_level = confidence_level
135+
super().__init__(
136+
length_scales=np.mean([gpr.length_scales for gpr in gpr_list], axis=0),
137+
search_space=search_space,
138+
)
139+
140+
def eval_acqf(self, x: torch.Tensor) -> torch.Tensor:
141+
x_noisy = x.unsqueeze(-2) + self._input_noise
142+
log_feas_probs = torch.zeros(x_noisy.shape[:-1], dtype=torch.float64)
143+
for gpr, threshold in zip(self._gpr_list, self._threshold_list):
144+
means, vars_ = gpr.posterior(x_noisy)
145+
sigmas = torch.sqrt(vars_ + self._stabilizing_noise)
146+
# NOTE(nabenabe): integral from a to b of f(x) is integral from -b to -a of f(-x).
147+
log_feas_probs += torch.special.log_ndtr((means - threshold) / sigmas)
148+
n_input_noise_samples = len(self._input_noise)
149+
n_risky_samples = math.ceil((1 - self._confidence_level) * n_input_noise_samples)
150+
log_feas_probs_at_risk, _ = torch.topk(
151+
log_feas_probs,
152+
k=n_risky_samples,
153+
dim=-1,
154+
largest=False,
155+
sorted=False,
156+
)
157+
return log_feas_probs_at_risk.logsumexp(dim=-1) - math.log(n_risky_samples)
158+
159+
72160
class ValueAtRisk(BaseAcquisitionFunc):
73161
def __init__(
74162
self,
75163
gpr: GPRegressor,
76164
search_space: SearchSpace,
77-
alpha: float,
165+
confidence_level: float,
78166
n_input_noise_samples: int,
79167
n_qmc_samples: int,
80168
qmc_seed: int | None,
81169
acqf_type: str,
82-
uniform_input_noise_ranges: torch.Tensor | None = None,
170+
uniform_input_noise_rads: torch.Tensor | None = None,
83171
normal_input_noise_stdevs: torch.Tensor | None = None,
84172
) -> None:
85-
assert 0 <= alpha <= 1
173+
assert 0 <= confidence_level <= 1
86174
self._gpr = gpr
87-
self._alpha = alpha
175+
self._confidence_level = confidence_level
88176
rng = np.random.RandomState(qmc_seed)
89-
self._input_noise = self._sample_input_noise(
90-
n_input_noise_samples, uniform_input_noise_ranges, normal_input_noise_stdevs, rng
177+
self._input_noise = _sample_input_noise(
178+
n_input_noise_samples,
179+
uniform_input_noise_rads,
180+
normal_input_noise_stdevs,
181+
seed=rng.random_integers(0, 2**31 - 1, size=1).item(),
91182
)
92-
seed = rng.random_integers(0, 2**31 - 1, size=1).item()
93183
self._fixed_samples = _sample_from_normal_sobol(
94-
dim=n_input_noise_samples, n_samples=n_qmc_samples, seed=seed
184+
dim=n_input_noise_samples,
185+
n_samples=n_qmc_samples,
186+
seed=rng.random_integers(0, 2**31 - 1, size=1).item(),
95187
)
96188
self._acqf_type = acqf_type
97189
super().__init__(length_scales=gpr.length_scales, search_space=search_space)
98190

99-
@staticmethod
100-
def _sample_input_noise(
101-
n_input_noise_samples: int,
102-
uniform_input_noise_ranges: torch.Tensor | None,
103-
normal_input_noise_stdevs: torch.Tensor | None,
104-
rng: np.random.RandomState,
105-
) -> torch.Tensor:
106-
seed = rng.random_integers(0, 2**31 - 1, size=1).item()
107-
108-
def _sample_input_noise(noise_params: torch.Tensor, gen: SobolGenerator) -> torch.Tensor:
109-
dim = noise_params.size(0)
110-
noisy_inds = torch.where(noise_params != 0.0)
111-
input_noise = torch.zeros(size=(n_input_noise_samples, dim), dtype=torch.float64)
112-
input_noise[:, noisy_inds[0]] = (
113-
gen(noisy_inds[0].size(0), n_input_noise_samples, seed) * noise_params[noisy_inds]
114-
)
115-
return input_noise
116-
117-
assert uniform_input_noise_ranges is not None or normal_input_noise_stdevs is not None
118-
if normal_input_noise_stdevs is not None:
119-
return _sample_input_noise(normal_input_noise_stdevs, _sample_from_normal_sobol)
120-
elif uniform_input_noise_ranges is not None:
121-
return _sample_input_noise(uniform_input_noise_ranges, _sample_from_sobol)
122-
else:
123-
raise ValueError(
124-
"Either `uniform_input_noise_ranges` or `normal_input_noise_stdevs` "
125-
"must be provided."
126-
)
127-
128191
def _value_at_risk(self, x: torch.Tensor) -> torch.Tensor:
129192
means, covar = self._gpr.joint_posterior(x.unsqueeze(-2) + self._input_noise)
130193
# TODO: Think of a better way to avoid numerical issue in the Cholesky decomposition.
131194
L, _ = torch.linalg.cholesky_ex(covar)
132195
posterior_samples = means.unsqueeze(-2) + self._fixed_samples @ L
133196
# If CVaR, use torch.topk instead of torch.quantile.
134-
return torch.quantile(posterior_samples, q=self._alpha, dim=-1)
197+
return torch.quantile(posterior_samples, q=self._confidence_level, dim=-1)
135198

136199
def eval_acqf(self, x: torch.Tensor) -> torch.Tensor:
137200
"""
@@ -148,3 +211,53 @@ def eval_acqf(self, x: torch.Tensor) -> torch.Tensor:
148211
raise NotImplementedError("NEI is not implemented yet.")
149212
else:
150213
raise ValueError(f"Unknown acqf_type: {self._acqf_type}")
214+
215+
216+
class ConstrainedLogValueAtRisk(BaseAcquisitionFunc):
217+
def __init__(
218+
self,
219+
gpr: GPRegressor,
220+
search_space: SearchSpace,
221+
constraints_gpr_list: list[GPRegressor],
222+
constraints_threshold_list: list[float],
223+
objective_confidence_level: float,
224+
feas_prob_confidence_level: float,
225+
n_input_noise_samples: int,
226+
n_qmc_samples: int,
227+
qmc_seed: int | None,
228+
acqf_type: str,
229+
uniform_input_noise_rads: torch.Tensor | None = None,
230+
normal_input_noise_stdevs: torch.Tensor | None = None,
231+
stabilizing_noise: float = 1e-12,
232+
) -> None:
233+
self._value_at_risk = ValueAtRisk(
234+
gpr=gpr,
235+
search_space=search_space,
236+
confidence_level=objective_confidence_level,
237+
n_input_noise_samples=n_input_noise_samples,
238+
n_qmc_samples=n_qmc_samples,
239+
qmc_seed=qmc_seed,
240+
acqf_type=acqf_type,
241+
uniform_input_noise_rads=uniform_input_noise_rads,
242+
normal_input_noise_stdevs=normal_input_noise_stdevs,
243+
)
244+
self._log_prob_at_risk = LogCumulativeProbabilityAtRisk(
245+
gpr_list=constraints_gpr_list,
246+
search_space=search_space,
247+
confidence_level=feas_prob_confidence_level,
248+
threshold_list=constraints_threshold_list,
249+
n_input_noise_samples=n_input_noise_samples,
250+
qmc_seed=qmc_seed,
251+
uniform_input_noise_rads=uniform_input_noise_rads,
252+
normal_input_noise_stdevs=normal_input_noise_stdevs,
253+
stabilizing_noise=stabilizing_noise,
254+
)
255+
assert torch.allclose(
256+
self._log_prob_at_risk._input_noise, self._value_at_risk._input_noise
257+
)
258+
super().__init__(self._value_at_risk.length_scales, search_space=search_space)
259+
260+
def eval_acqf(self, x: torch.Tensor) -> torch.Tensor:
261+
return self._value_at_risk.eval_acqf(x).clamp_min_(
262+
_EPS
263+
).log_() + self._log_prob_at_risk.eval_acqf(x)

package/samplers/value_at_risk/example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@ def objective(trial: optuna.Trial) -> float:
2525

2626

2727
RobustGPSampler = optunahub.load_module("samplers/value_at_risk").RobustGPSampler
28-
sampler = RobustGPSampler(seed=0, uniform_input_noise_ranges={"x": 0.5, "y": 0.5})
28+
sampler = RobustGPSampler(seed=0, uniform_input_noise_rads={"x": 0.5, "y": 0.5})
2929
study = optuna.create_study(sampler=sampler)
3030
study.optimize(objective, n_trials=50)

0 commit comments

Comments
 (0)