미분 없이 최적화하기 — 정규분포 하나를 지형에 맞춰 길들이는 알고리즘을 밑바닥부터 구현하고 pycma와 비교합니다.
Published
August 25, 2026
직접 구현한 CMA-ES가 Rosenbrock 골짜기를 따라 내려가는 모습. 주황색 타원이 “지금 어디를 뒤지고 있는가”입니다.
CMA-ES(Covariance Matrix Adaptation Evolution Strategy)는 기울기(gradient)를 쓸 수 없는 문제에서 사실상 표준처럼 쓰이는 최적화 알고리즘입니다. 시뮬레이터 튜닝, 로봇 제어기 파라미터, 하이퍼파라미터 탐색처럼 “함수값은 알 수 있는데 미분은 모르겠는” 상황이 딱 그 무대죠.
이 글은 논문을 요약하는 대신, 코드를 한 줄씩 쌓아 올리면서 왜 각 항이 필요한지를 따라갑니다.
최소값은 \mathbf{x}^*=(1,\dots,1) 에서 0. 그런데 이 함수는 바나나처럼 휘어진 아주 좁고 평평한 골짜기를 가지고 있어서, “무작정 가까운 쪽으로 조금씩” 움직이는 방법은 골짜기 바닥에서 좌우로 튕기기만 하고 앞으로 못 나갑니다.
왜 하필 이 함수가 단골일까요? 세 가지가 겹쳐 있기 때문입니다. ① x_{i+1} 의 최적값이 x_i^2 에 묶여 있어 변수끼리 얽혀 있고(non-separable), 한 변수씩 따로 다루는 방법으로는 원리적으로 풀리지 않습니다. ② 최적점에서의 헤시안 조건수가 약 2,500이라 한 방향이 다른 방향보다 50배쯤 좁습니다. ③ 결정적으로 골짜기 바닥이 포물선이라 위치에 따라 “좋은 방향”이 계속 회전합니다. 조건수만 큰 함수는 올바른 타원을 한 번 배우면 끝이지만, Rosenbrock은 그 타원을 계속 따라 돌려야 합니다. 즉 이 함수는 CMA-ES가 하는 일 — 탐색 분포의 크기와 방향을 지속적으로 적응시키는 능력 — 을 정확히 겨냥해 시험합니다. (다만 어렵기만 한 문제는 아닙니다. 봉우리가 하나뿐이라 CMA-ES는 몇 천 번 평가로 기계 정밀도까지 도달합니다. 다봉 문제는 뒤에서 Rastrigin으로 따로 봅니다.)
Note잠깐, “헤시안 조건수”가 뭔가요?
헤시안H_{ij} = \partial^2 f / \partial x_i \partial x_j 는 2차 미분을 모아 놓은 행렬, 즉 방향별 곡률표입니다. 최적점에서는 기울기가 0이므로 주변 지형이 사실상 H 하나로 결정됩니다.
f(\mathbf{x}^* + \boldsymbol{\delta}) \approx f(\mathbf{x}^*) + \tfrac{1}{2}\boldsymbol{\delta}^\top H \boldsymbol{\delta}
Rosenbrock의 (1,1) 에서는 H = \begin{pmatrix} 802 & -400 \\ -400 & 200 \end{pmatrix} 이고, 고유분해하면
고유값 \lambda
방향
의미
가파른 축
1001.6
(-0.895,\ 0.446)
골짜기를 가로지르는 벽
완만한 축
0.399
(-0.446,\ -0.895)
골짜기를 따라가는 바닥
조건수는 이 둘의 비율 \kappa(H) = \lambda_{\max}/\lambda_{\min} \approx 2508 입니다. \kappa=1 이면 완전한 그릇(구형)이라 가장 쉽고, 클수록 “한 방향은 절벽, 다른 방향은 평지”가 됩니다.
등고선 타원의 축 길이는 1/\sqrt{\lambda} 에 비례하므로, 모양의 비율은 \sqrt{\kappa} \approx 50 — 위 문단의 “50배 좁다”가 이 숫자입니다.
이게 CMA-ES와 직결되는 이유: 학습된 공분산은 C \propto H^{-1} 로 갑니다. H 에서 가파른 방향은 H^{-1} 에서 작아지고(→ 탐색 타원의 짧은 축), 평평한 방향은 커집니다(→ 긴 축). 즉 “가파른 쪽은 조심조심, 평평한 쪽은 성큼성큼” 을 스스로 배우는 것이고, 잘 배웠다면 탐색 타원의 축 비율이 \sqrt{\kappa} 에 수렴해야 합니다. 3.3절 (6)에서 실제로 확인합니다.
덧붙여, 경사하강법의 수렴 속도가 \big(\frac{\kappa-1}{\kappa+1}\big)^2 로 느려진다는 고전적 결과도 같은 \kappa 입니다. 뉴턴법은 H^{-1} 을 곱해 \kappa 를 1로 만들어 버리고, CMA-ES는 미분 없이C \approx H^{-1} 을 추정해 같은 효과를 냅니다.
Figure 1: Rosenbrock 함수의 log10 등고선. 빨간 별이 최적점. 골짜기가 ‘휘어’ 있다는 게 핵심입니다.
Note파인만식 요약: 왜 어려운가?
안대를 쓰고 계곡을 내려간다고 해봅시다. 발로 땅을 툭툭 쳐서 “여기가 더 낮네”만 알 수 있어요(= 함수값만 안다). 계곡이 동서로는 좁고 남북으로는 완만하게 휘어 있다면, 사방으로 똑같은 보폭으로 발을 뻗는 사람은 대부분 벽에 부딪힙니다. 잘 내려가려면 “휘어진 방향으로는 크게, 좁은 방향으로는 작게” 발을 뻗어야 하죠. CMA-ES가 학습하는 게 정확히 그 발 뻗는 모양(공분산 행렬) 입니다.
🐣 1. 가장 단순한 진화 전략: (1+1)-ES
규칙은 세 줄입니다.
지금 위치 \mathbf{x} 주변에 정규분포로 후보 하나를 뽑는다: \mathbf{y} = \mathbf{x} + \sigma\,\mathcal{N}(0, I)
보폭 경로 \|\mathbf{p}_\sigma\| 가 비정상적으로 커졌다는 건 \sigma 가 빠르게 증가하는 중이라는 뜻입니다. 이때 공분산까지 같이 부풀리면 발산하기 쉬워서, rank-one 갱신을 잠시 꺼두는 안전장치가 hsig 입니다. 논문에는 한 줄로 나오지만 없으면 고차원에서 꽤 불안정해집니다.
3.3 숫자로 따라가는 한 세대
수식만 보면 추상적이니, 2차원 문제에서 실제 숫자가 어떻게 바뀌는지 한 세대를 통째로 손으로 따라가 보겠습니다. 아래 값들은 전부 실제 실행 결과입니다.
상위 3개만 남기고, 정규화된 스텝\mathbf{y}_i = (\mathbf{x}_i - \mathbf{m})/\sigma 에 가중치를 곱해 더합니다.
idx = np.argsort(fv)y_sel = (X[idx[:es2.mu]] - es2.m) / es2.sigmayw = es2.w @ y_selfor i, y inenumerate(y_sel):print(f"{i+1}등: y = {np.round(y,4)}, w = {es2.w[i]:.4f}, w*y = {np.round(es2.w[i]*y,4)}")print("\n<y>_w =", np.round(yw, 4), " (가중 평균 방향, 길이 %.4f)"% np.linalg.norm(yw))m_new = es2.m + es2.sigma * ywprint("m_old =", np.round(es2.m, 4))print("m_new = m_old + sigma * <y>_w =", np.round(m_new, 4))
1등: y = [-0.0168 -0.853 ], w = 0.6370, w*y = [-0.0107 -0.5434]
2등: y = [ 0.3047 -1.04 ], w = 0.2846, w*y = [ 0.0867 -0.2959]
3등: y = [ 0.1278 -0.3162], w = 0.0784, w*y = [ 0.01 -0.0248]
<y>_w = [ 0.086 -0.8642] (가중 평균 방향, 길이 0.8684)
m_old = [-1. 1.5]
m_new = m_old + sigma * <y>_w = [-0.9656 1.1543]
Note왜 \sigma 로 나눴다가 다시 곱하나?
\mathbf{y} 는 “보폭 1개 단위로 몇 걸음” 인지를 나타내는 무차원 값입니다. 보폭이 변해도 방향 정보는 그대로 재사용할 수 있게 하려는 것이죠. 진화 경로 \mathbf{p}_c,\mathbf{p}_\sigma 도 모두 이 \mathbf{y} 단위로 누적됩니다.
(3) 보폭 업데이트: 길이 하나로 판단하기
\mathbf{p}_\sigma 는 C^{-1/2} 로 좌표를 되돌린 스텝을 누적합니다. 첫 세대엔 C=I 라 그냥 \langle\mathbf{y}\rangle_w 그대로예요.
대각 성분의 비율이 1 : 10^{-2} : 10^{-4} 로 H^{-1} 과 거의 같고, 축 비율도 이론값 100 근처(≈120)로 맞아떨어집니다. 미분을 한 번도 계산하지 않고, 오직 “누가 더 나은가”라는 비교만으로 곡률 정보를 복원한 셈입니다. CMA-ES를 “미분 없는 준-뉴턴법(derivative-free quasi-Newton)”이라고 부르는 이유예요.
3.4 세 알고리즘 비교
비교 그림 코드
ev = [t[0] for t in log]fbest = np.minimum.accumulate([t[1] for t in log])fig, ax = plt.subplots(figsize=(7, 4.2), dpi=110)ax.semilogy(np.arange(1, len(h1) +1), np.maximum(h1, 1e-16), label="(1+1)-ES")lam2 =4+int(3* np.log(4))ax.semilogy(np.arange(1, len(h2) +1) * lam2, np.maximum(h2, 1e-16), label="(μ/μ_w, λ)-ES")ax.semilogy(ev, np.maximum(fbest, 1e-16), lw=2, label="CMA-ES (MiniCMA)")ax.set_xlabel("function evaluations"); ax.set_ylabel("best $f$")ax.grid(alpha=.3); ax.legend(); plt.show()
Figure 2: 같은 예산에서의 수렴 곡선. 세로축은 log 스케일입니다. CMA-ES만 기계 정밀도까지 내려갑니다.
3.5 σ와 조건수는 어떻게 변했나
코드 보기
sig = [t[2] for t in log]cond = [np.linalg.cond(t[4]) for t in log]fig, ax = plt.subplots(1, 2, figsize=(10, 3.6), dpi=110)ax[0].semilogy(ev, sig); ax[0].set_title("step-size $\\sigma$")ax[1].semilogy(ev, cond); ax[1].set_title("condition number of $C$")for a in ax: a.set_xlabel("evaluations"); a.grid(alpha=.3)plt.tight_layout(); plt.show()
Figure 4: MiniCMA와 pycma의 수렴 곡선. 뼈대가 같으니 궤적도 비슷합니다 — 차이는 대부분 안정성 장치와 종료 조건에서 옵니다.
4.2 단조 변환 불변성 확인
앞에서 말한 “등수만 쓴다”를 직접 검증해 봅시다.
def run(f, seed=7):"""종료 조건까지 동일하게 만들기 위해 평가 횟수로만 끊는다.""" opts = {'verbose': -9, 'seed': seed, 'maxfevals': 600,'tolfun': 0, 'tolfunhist': 0, 'tolx': 0, 'tolflatfitness': 10**9} es = cma.CMAEvolutionStrategy(4* [0.1], 0.4, opts) es.optimize(f)return es.result.xbesta = run(cma.ff.rosen)b = run(lambda x: np.log(cma.ff.rosen(x) +1e-30)) # 단조 증가 변환c = run(lambda x: cma.ff.rosen(x) **3) # 이것도 단조 증가print("f :", np.round(a, 8))print("log f :", np.round(b, 8))print("f^3 :", np.round(c, 8))print("완전히 동일한가?", np.array_equal(a, b) and np.array_equal(a, c))
f : [0.94105666 0.88417634 0.77034548 0.59164007]
log f : [0.94105666 0.88417634 0.77034548 0.59164007]
f^3 : [0.94105666 0.88417634 0.77034548 0.59164007]
완전히 동일한가? True
비트 단위까지 완전히 동일한 궤적이 나옵니다. 정렬 결과가 같으면 이후 계산이 전부 같기 때문이에요.
Warning단, 종료 조건은 불변이 아닙니다
위에서 tolfun 같은 함수값 기반 종료 조건을 일부러 꺼두었습니다. 켜 두면 f 와 f^3 은 “함수값 변화량”이 다르므로 서로 다른 시점에 멈춰 결과가 조금 달라집니다. 즉 탐색 자체는 불변, 멈추는 시점은 스케일에 의존합니다. 목적함수 스케일을 크게 바꿨다면 tolfun 도 같이 조정하세요.
🧭 5. 실전 감각
Tip하이퍼파라미터는 사실상 두 개뿐
x_0: 최적해가 있을 법한 곳. 모르면 정규화된 좌표계에서 중앙.
\sigma_0: 각 변수의 “탐색 반경”. 최적해가 x_0 \pm 3\sigma_0 안에 있도록 잡는 것이 경험칙입니다.
그래서 실무 팁: 모든 변수를 [0, 1] 로 정규화하고 x_0 = 0.5, \sigma_0 = 0.2\!\sim\!0.3 으로 시작하세요. 변수마다 스케일이 크게 다르면 CMA_stds 옵션으로 개별 지정할 수 있습니다.
상황
처방
차원 N \lesssim 100
기본 CMA-ES 그대로
N 이 수백~수천
'CMA_diagonal': True 또는 sep-CMA / LM-CMA 계열
지역해가 많다
restarts=9, incpopsize=2 (IPOP), 또는 BIPOP
평가에 노이즈가 있다
'noise_handling': True, 인구수 증가
평가가 매우 비싸다 (< 100회)
베이지안 최적화가 더 적합
미분을 쓸 수 있다
그냥 경사법을 쓰세요 — CMA-ES는 O(N^2) 의 값 평가가 필요합니다
CautionCMA-ES가 답이 아닌 경우
평가 예산이 수십 번뿐이거나, 변수가 대부분 이산/범주형이거나, 미분이 정확히 계산되는 문제라면 다른 도구가 낫습니다. CMA-ES의 스위트 스팟은 연속 변수 N \approx 3\sim100, 평가 10^3\sim10^5 회, 노이즈·불연속·비볼록이 섞인 블랙박스입니다.
🗂 6. 정리 & 코드
이 글에서 만든 스크립트는 pycma fork의 study/ 폴더에 정리해 두었습니다.
git clone -b study https://github.com/curieuxjy/pycma.gitcd pycma/studypython 01_random_search.py # (1+1)-ESpython 02_mu_lambda_es.py # (mu/mu_w, lambda)-ESpython 03_cmaes_from_scratch.py # MiniCMApython 04_pycma_basics.py # pycma 사용법python 05_visualize.py out.gif # 애니메이션 생성
한 문장으로 요약하면 —
CMA-ES는 “다음에 어디를 뒤질까”를 정규분포 하나로 표현하고, 성공한 스텝들의 방향과 길이를 기억해 그 분포의 중심·크기·모양을 스스로 고쳐 나가는 알고리즘이다.