Skip to content

Commit 868df34

Browse files
committed
Update SafeCMA
1 parent d052593 commit 868df34

File tree

5 files changed

+104
-31
lines changed

5 files changed

+104
-31
lines changed

.github/workflows/tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: Install dependencies
2121
run: |
2222
python -m pip install --upgrade pip setuptools
23-
pip install --progress-bar off numpy matplotlib scipy mypy flake8 black gpytorch torch
23+
pip install --progress-bar off numpy matplotlib scipy mypy flake8 black torch gpytorch
2424
- run: flake8 . --show-source --statistics
2525
- run: black --check .
2626
- run: mypy cmaes
@@ -38,7 +38,7 @@ jobs:
3838
architecture: x64
3939
- name: Install dependencies
4040
run: |
41-
python -m pip install --upgrade pip setuptools numpy scipy hypothesis gpytorch torch
41+
python -m pip install --upgrade pip setuptools numpy scipy hypothesis torch gpytorch
4242
pip install --progress-bar off .
4343
- run: python -m unittest
4444
test-numpy2:
@@ -51,7 +51,7 @@ jobs:
5151
architecture: x64
5252
- name: Install dependencies
5353
run: |
54-
python -m pip install --upgrade pip setuptools scipy hypothesis gpytorch torch
54+
python -m pip install --upgrade pip setuptools scipy hypothesis torch gpytorch
5555
python -m pip install --pre --upgrade numpy
5656
pip install --progress-bar off .
5757
- run: python -m unittest

README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,81 @@ The full source code is available [here](./examples/catcma.py).
316316

317317
</details>
318318

319+
#### Safe CMA [Uchida et al. 2024]
320+
Safe CMA-ES is a variant of CMA-ES for safe optimization. Safe optimization is formulated as a special type of constrained optimization problem aiming to solve the optimization problem with fewer evaluations of the solutions whose safety function values exceed the safety thresholds. The safe CMA-ES requires safe seeds that do not violate the safety constraints. Note that the safe CMA-ES is designed for noiseless safe optimization. This module needs `torch` and `gpytorch`.
321+
322+
<details>
323+
<summary>Source code</summary>
324+
325+
```python
326+
import numpy as np
327+
from cmaes.safe_cma import SafeCMA
328+
329+
# objective function
330+
def quadratic(x):
331+
coef = 1000 ** (np.arange(dim) / float(dim - 1))
332+
return np.sum((x * coef) ** 2)
333+
334+
# safety function
335+
def safe_function(x):
336+
return x[0]
337+
338+
"""
339+
example with single safety function
340+
"""
341+
if __name__ == "__main__":
342+
# number of dimensions
343+
dim = 5
344+
345+
# safe seeds
346+
safe_seeds_num = 10
347+
safe_seeds = (np.random.rand(safe_seeds_num, dim) * 2 - 1) * 5
348+
safe_seeds[:,0] = - np.abs(safe_seeds[:,0])
349+
350+
# evaluation of safe seeds (with a single safety function)
351+
seeds_evals = np.array([ quadratic(x) for x in safe_seeds ])
352+
seeds_safe_evals = np.stack([ [safe_function(x)] for x in safe_seeds ])
353+
safety_threshold = np.array([0])
354+
355+
# optimizer (safe CMA-ES)
356+
optimizer = SafeCMA(
357+
sigma=1.,
358+
safety_threshold=safety_threshold,
359+
safe_seeds=safe_seeds,
360+
seeds_evals=seeds_evals,
361+
seeds_safe_evals=seeds_safe_evals,
362+
)
363+
364+
unsafe_eval_counts = 0
365+
best_eval = np.inf
366+
367+
for generation in range(400):
368+
solutions = []
369+
for _ in range(optimizer.population_size):
370+
# Ask a parameter
371+
x = optimizer.ask()
372+
value = quadratic(x)
373+
safe_value = np.array([safe_function(x)])
374+
375+
# save best eval
376+
best_eval = np.min((best_eval, value))
377+
unsafe_eval_counts += (safe_value > safety_threshold)
378+
379+
solutions.append((x, value, safe_value))
380+
381+
# Tell evaluation values.
382+
optimizer.tell(solutions)
383+
384+
print(f"#{generation} ({best_eval} {unsafe_eval_counts})")
385+
386+
if optimizer.should_stop():
387+
break
388+
```
389+
390+
The full source code is available [here](./examples/safecma.py).
391+
392+
</details>
393+
319394

320395
#### Separable CMA-ES [Ros and Hansen 2008]
321396

cmaes/_safe_cma.py

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def safe_function(x):
4444
safe_seeds = (np.random.rand(safe_seeds_num, dim) * 2 - 1) * 5
4545
safe_seeds[:, 0] = - np.abs(safe_seeds[:, 0])
4646
47-
# evaluation of safe seeds (with multiple safety functions)
47+
# evaluation of safe seeds (with a single safety function)
4848
seeds_evals = np.array([quadratic(x) for x in safe_seeds])
4949
seeds_safe_evals = np.stack([[safe_function(x)] for x in safe_seeds])
5050
safety_threshold = np.array([0])
@@ -121,6 +121,8 @@ def safe_function(x):
121121
A covariance matrix (optional).
122122
"""
123123

124+
# Paper: https://arxiv.org/abs/2405.10534v1
125+
124126
def __init__(
125127
self,
126128
safe_seeds: np.ndarray,
@@ -147,7 +149,7 @@ def __init__(
147149
assert n_dim > 1, "The dimension of mean must be larger than 1"
148150

149151
if population_size is None:
150-
population_size = 4 + math.floor(3 * math.log(n_dim)) # (eq. 48)
152+
population_size = 4 + math.floor(3 * math.log(n_dim))
151153
assert population_size > 0, "popsize must be non-zero positive value."
152154

153155
mu = population_size // 2
@@ -193,16 +195,16 @@ def __init__(
193195
assert c1 <= 1 - cmu, "invalid learning rate for the rank-one update"
194196
assert cmu <= 1 - c1, "invalid learning rate for the rank-μ update"
195197

196-
cm = 1 # (eq. 54)
198+
cm = 1
197199

198-
# learning rate for the cumulation for the step-size control (eq.55)
200+
# learning rate for the cumulation for the step-size control
199201
c_sigma = (mu_eff + 2) / (n_dim + mu_eff + 5)
200202
d_sigma = 1 + 2 * max(0, math.sqrt((mu_eff - 1) / (n_dim + 1)) - 1) + c_sigma
201203
assert (
202204
c_sigma < 1
203205
), "invalid learning rate for cumulation for the step-size control"
204206

205-
# learning rate for cumulation for the rank-one update (eq.56)
207+
# learning rate for cumulation for the rank-one update
206208
cc = (4 + mu_eff / n_dim) / (n_dim + 4 + 2 * mu_eff / n_dim)
207209
assert cc <= 1, "invalid learning rate for cumulation for the rank-one update"
208210

@@ -218,7 +220,7 @@ def __init__(
218220
self._d_sigma = d_sigma
219221
self._cm = cm
220222

221-
# E||N(0, I)|| (p.28)
223+
# E||N(0, I)||
222224
self._chi_n = math.sqrt(self._n_dim) * (
223225
1.0 - (1.0 / (4.0 * self._n_dim)) + 1.0 / (21.0 * (self._n_dim**2))
224226
)
@@ -270,7 +272,6 @@ def __init__(
270272
self._funhist_values = np.empty(self._funhist_term * 2)
271273

272274
def _compute_lipschitz_constant(self) -> np.ndarray:
273-
274275
likelihood = gpytorch.likelihoods.GaussianLikelihood(
275276
noise_constraint=gpytorch.constraints.GreaterThan(0)
276277
)
@@ -288,9 +289,8 @@ def _compute_lipschitz_constant(self) -> np.ndarray:
288289
evals_std = np.std(target_safe_evals, axis=0)
289290
modified_evals = (target_safe_evals - evals_mean) / evals_std
290291

291-
# function that returns the norm of gradient
292+
# function that returns the negative norm of gradient
292293
def df(x: np.ndarray, model: ExactGPModel) -> torch.Tensor:
293-
294294
out_scalar = x.ndim == 1
295295
x = np.atleast_2d(x)
296296

@@ -403,7 +403,7 @@ def _init_distribution(self, sigma: float) -> tuple[np.ndarray, float]:
403403
# set initial mean vector
404404
best_seed_id = np.argmin(self.seeds_evals)
405405
mean = self.safe_seeds[best_seed_id]
406-
self._mean = mean.copy()
406+
self._mean = mean.copy() # (eq. 26)
407407

408408
# set initial step-size
409409
if len(self.sampled_points) > 1:
@@ -422,7 +422,7 @@ def _init_distribution(self, sigma: float) -> tuple[np.ndarray, float]:
422422
slack = self.safety_threshold - self.seeds_safe_evals[best_seed_id]
423423
delta = np.min((slack) / self.lipschitz_constant)
424424
gauss_tr = np.sqrt(scipy.stats.chi2.ppf(self.gamma, df=self._n_dim))
425-
sigma = sigma * np.min((delta / gauss_tr, 1))
425+
sigma = sigma * np.min((delta / gauss_tr, 1)) # (eq. 27)
426426

427427
return mean, sigma
428428

@@ -461,7 +461,9 @@ def _sample_solution(self) -> np.ndarray:
461461

462462
# radius: radius of trust region around evaluated points
463463
slack = self.safety_threshold[:, None, None] - prev_safe_evals[None, :, :]
464-
radius = np.min(slack / self.lipschitz_constant[:, None, None], axis=(0, 2))
464+
radius = np.min(
465+
slack / self.lipschitz_constant[:, None, None], axis=(0, 2)
466+
) # (eq.13)
465467

466468
radius[radius < 0] = -np.inf
467469
# dist: distance between current samples and evaluated points
@@ -472,7 +474,7 @@ def _sample_solution(self) -> np.ndarray:
472474
closest_z_sample = sampled_z_points[argmin_sample_id]
473475

474476
ratio = invalid_dist / dist[argmin_sample_id]
475-
z = (1 - ratio) * z + ratio * closest_z_sample
477+
z = (1 - ratio) * z + ratio * closest_z_sample # (eq.15)
476478

477479
y = cast(np.ndarray, B.dot(np.diag(D)).dot(B.T)).dot(z) # ~ N(0, C)
478480
x = self._mean + self._sigma * y # ~ N(m, σ^2 C)
@@ -496,17 +498,16 @@ def _repair_infeasible_params(self, param: np.ndarray) -> np.ndarray:
496498
return param
497499

498500
def tell(self, solutions: list[tuple[np.ndarray, float, float]]) -> None:
499-
500501
self._naive_cma_update(solutions)
501502

502503
X = np.stack([s[0] for s in solutions])
503504
safe_evals = np.array([s[2] for s in solutions])
504505

505506
self._add_evaluated_point(X, safe_evals)
506507

507-
self.lipschitz_constant = self._compute_lipschitz_constant()
508+
self.lipschitz_constant = self._compute_lipschitz_constant() # (eq.19)
508509
if len(self.sampled_safe_evals) < self.sample_num_lip:
509-
exponent = 1 / len(self.sampled_safe_evals)
510+
exponent = 1 / len(self.sampled_safe_evals) # (eq.22)
510511
self.lipschitz_constant *= self.init_L_base**exponent
511512

512513
inv_num = np.sum(safe_evals > self.safety_threshold, dtype=np.float32)
@@ -517,7 +518,7 @@ def tell(self, solutions: list[tuple[np.ndarray, float, float]]) -> None:
517518
else:
518519
self.lip_penalty_coef /= self.lip_penalty_dec_rate
519520
self.lip_penalty_coef = np.max((self.lip_penalty_coef, 1))
520-
self.lipschitz_constant *= self.lip_penalty_coef
521+
self.lipschitz_constant *= self.lip_penalty_coef # (eq.24)
521522

522523
def _add_evaluated_point(self, X: np.ndarray, safe_evals: np.ndarray) -> None:
523524
self.sampled_points = np.concatenate([self.sampled_points, X], axis=0)
@@ -551,8 +552,8 @@ def _naive_cma_update(
551552
y_k = (x_k - self._mean) / self._sigma # ~ N(0, C)
552553

553554
# Selection and recombination
554-
y_w = np.sum(y_k[: self._mu].T * self._weights[: self._mu], axis=1) # eq.41
555-
self._mean += self._cm * self._sigma * y_w
555+
y_w = np.sum(y_k[: self._mu].T * self._weights[: self._mu], axis=1)
556+
self._mean += self._cm * self._sigma * y_w # (eq.7)
556557

557558
# Step-size control
558559
C_2 = cast(
@@ -565,28 +566,26 @@ def _naive_cma_update(
565566
norm_p_sigma = np.linalg.norm(self._p_sigma)
566567
self._sigma *= np.exp(
567568
(self._c_sigma / self._d_sigma) * (norm_p_sigma / self._chi_n - 1)
568-
)
569+
) # (eq.8)
569570
self._sigma = min(self._sigma, _SIGMA_MAX)
570571

571572
# Covariance matrix adaption
572573
h_sigma_cond_left = norm_p_sigma / math.sqrt(
573574
1 - (1 - self._c_sigma) ** (2 * (self._g + 1))
574575
)
575576
h_sigma_cond_right = (1.4 + 2 / (self._n_dim + 1)) * self._chi_n
576-
h_sigma = 1.0 if h_sigma_cond_left < h_sigma_cond_right else 0.0 # (p.28)
577+
h_sigma = 1.0 if h_sigma_cond_left < h_sigma_cond_right else 0.0
577578

578-
# (eq.45)
579579
self._pc = (1 - self._cc) * self._pc + h_sigma * math.sqrt(
580580
self._cc * (2 - self._cc) * self._mu_eff
581581
) * y_w
582582

583-
# (eq.47)
584583
rank_one = np.outer(self._pc, self._pc)
585584
rank_mu = np.sum(
586585
np.array([w * np.outer(y, y) for w, y in zip(self._weights, y_k)]), axis=0
587586
)
588587

589-
delta_h_sigma = (1 - h_sigma) * self._cc * (2 - self._cc) # (p.28)
588+
delta_h_sigma = (1 - h_sigma) * self._cc * (2 - self._cc)
590589
assert delta_h_sigma <= 1
591590

592591
self._C = (
@@ -599,7 +598,7 @@ def _naive_cma_update(
599598
* self._C
600599
+ self._c1 * rank_one
601600
+ self._cmu * rank_mu
602-
)
601+
) # (eq.9)
603602

604603
def should_stop(self) -> bool:
605604
B, D = self._eigen_decomposition()
@@ -686,7 +685,6 @@ def __init__(
686685
likelihood: gpytorch.likelihoods.Likelihood,
687686
kernel: gpytorch.kernels.Kernel,
688687
) -> None:
689-
690688
super(ExactGPModel, self).__init__(
691689
torch.from_numpy(train_x), torch.from_numpy(train_y), likelihood
692690
)

examples/safecma.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def safe_function(x):
2424
safe_seeds = (np.random.rand(safe_seeds_num, dim) * 2 - 1) * 5
2525
safe_seeds[:, 0] = -np.abs(safe_seeds[:, 0])
2626

27-
# evaluation of safe seeds (with multiple safety functions)
27+
# evaluation of safe seeds (with a single safety function)
2828
seeds_evals = np.array([quadratic(x) for x in safe_seeds])
2929
seeds_safe_evals = np.stack([[safe_function(x)] for x in safe_seeds])
3030
safety_threshold = np.array([0])

requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# install_requires
22
numpy>=1.20.0
3-
gpytorch
43
torch
4+
gpytorch
55

66
# visualization
77
matplotlib

0 commit comments

Comments
 (0)