개념
최적화(Optimization)의 의미
최적화(optimization)는 어떤 함수(function)를 최소화(minimize)하거나 최대화(maximize)하는 입력값(input value)을 찾는 일입니다. 머신러닝(machine learning)에서 함수는 손실(loss)입니다. 입력은 모델 가중치(model weight)입니다. 학습(training)은 최적화입니다.
L(w) 최소화. 여기서:
L = 손실 함수(loss function)
w = 모델 가중치(model weights, 수백만 파라미터일 수 있음)
기본 경사하강법(Vanilla gradient descent)
가장 단순한 옵티마이저(optimizer)입니다. 모든 가중치(weight)에 대한 손실 기울기(loss gradient)를 계산합니다. 각 가중치를 기울기의 반대 방향으로 움직입니다. 보폭(step)은 학습률(learning rate)로 스케일(scale)합니다.
w = w - lr * gradient
이 한 줄이 전체 알고리즘(algorithm)입니다.
graph TD
A["* 시작점 (손실 큼)"] --> B["기울기 방향을 따라 아래로 이동"]
B --> C["최솟값에 접근"]
C --> D["o 최솟값 (손실 작음)"]
학습률(Learning rate): 가장 중요한 하이퍼파라미터(hyperparameter)
학습률(learning rate)은 보폭(step size)을 제어합니다. 수렴(convergence)의 거의 모든 것을 결정합니다.
graph LR
subgraph TooLarge["너무 큼 (lr = 1.0)"]
A1["1단계"] -->|지나침| A2["2단계"]
A2 -->|지나침| A3["3단계"]
A3 -->|발산| A4["..."]
end
subgraph TooSmall["너무 작음 (lr = 0.0001)"]
B1["1단계"] -->|아주 작은 보폭| B2["2단계"]
B2 -->|아주 작은 보폭| B3["3단계"]
B3 -->|만 단계 뒤| B4["최솟값"]
end
subgraph JustRight["적당함 (lr = 0.01)"]
C1["시작"] --> C2["..."] --> C3["약 100단계만에 수렴"]
end
맞는 학습률(learning rate)을 주는 공식은 없습니다. 실험(experiment)으로 찾습니다. 흔한 시작점은 Adam에서는 0.001, 모멘텀(momentum)이 있는 SGD에서는 0.01입니다.
SGD, 배치(batch), 미니배치(mini-batch)
기본 경사하강법(vanilla gradient descent)은 한 단계(step)를 움직이기 전에 전체 데이터셋(dataset) 위에서 기울기(gradient)를 계산합니다. 이를 배치 경사하강법(batch gradient descent)이라고 합니다. 안정적이지만 느립니다.
확률적 경사하강법(SGD, Stochastic Gradient Descent)은 무작위 샘플(random sample) 하나에서 기울기(gradient)를 계산하고 바로 한 단계(step)를 움직입니다. 잡음(noise)이 크지만 빠릅니다.
미니배치 경사하강법(mini-batch gradient descent)은 둘 사이입니다. 작은 배치(batch), 예를 들어 32, 64, 128, 256개 샘플(sample) 위에서 기울기를 계산하고 한 단계 움직입니다. 실제로는 대부분 이 방식을 사용합니다.
| 변형(Variant) | 배치 크기(Batch size) | 기울기 품질(Gradient quality) | 단계당 속도(Speed per step) | 잡음(Noise) |
|---|
| 배치 경사하강법(Batch GD) | 전체 데이터셋(dataset) | 정확함 | 느림 | 없음 |
| 확률적 경사하강법(SGD) | 샘플 1개 | 매우 잡음이 큼 | 빠름 | 높음 |
| 미니배치(Mini-batch) | 32-256개 | 좋은 추정치 | 균형적 | 중간 |
SGD와 미니배치(mini-batch)의 잡음(noise)은 버그(bug)가 아닙니다. 얕은 지역 최솟값(local minimum)과 안장점(saddle point)에서 빠져나오는 데 도움이 됩니다.
모멘텀(Momentum): 비탈을 굴러 내려가는 공
기본 경사하강법(vanilla gradient descent)은 현재 기울기(gradient)만 봅니다. 좁은 계곡(narrow valley)에서 기울기가 지그재그(zigzag)로 움직이면 진행이 느립니다. 모멘텀(momentum)은 과거 기울기(past gradient)를 속도 항(velocity term)에 누적해 이 문제를 완화합니다.
v = beta * v + gradient
w = w - lr * v
비유하자면 비탈을 굴러 내려가는 공과 같습니다. 작은 요철(bump)마다 멈추고 다시 출발하지 않습니다. 일관된 방향에서는 속도를 만들고, 진동(oscillation)은 감쇠시킵니다.
graph TD
subgraph Without["모멘텀 없음 (지그재그, 느림)"]
W1["시작"] -->|왼쪽| W2[" "]
W2 -->|오른쪽| W3[" "]
W3 -->|왼쪽| W4[" "]
W4 -->|오른쪽| W5[" "]
W5 -->|왼쪽| W6[" "]
W6 --> W7["최솟값"]
end
subgraph With["모멘텀 있음 (매끄럽고 빠름)"]
M1["시작"] --> M2[" "] --> M3[" "] --> M4["최솟값"]
end
beta는 보통 0.9를 사용하며, 과거 이력(history)을 얼마나 유지할지를 정합니다. beta가 높으면 경로가 더 매끄럽지만 방향이 바뀔 때는 더 느리게 반응합니다.
Adam: 적응형 학습률(adaptive learning rate)
서로 다른 가중치(weight)에는 서로 다른 학습률(learning rate)이 필요합니다. 드물게 큰 기울기를 받는 가중치는 그때 더 크게 움직여야 합니다. 계속 거대한 기울기를 받는 가중치는 더 작게 움직여야 합니다.
Adam(Adaptive Moment Estimation)은 가중치(weight)마다 두 가지를 추적합니다.
- 1차 모멘트(first moment)
m: 기울기(gradient)의 이동 평균(running average)입니다. 모멘텀(momentum)과 비슷합니다.
- 2차 모멘트(second moment)
v: 제곱 기울기(squared gradient)의 이동 평균입니다. 기울기 크기(gradient magnitude)를 나타냅니다.
m = beta1 * m + (1 - beta1) * gradient
v = beta2 * v + (1 - beta2) * gradient^2
m_hat = m / (1 - beta1^t) # 편향 보정(bias correction)
v_hat = v / (1 - beta2^t) # 편향 보정(bias correction)
w = w - lr * m_hat / (sqrt(v_hat) + epsilon)
핵심은 sqrt(v_hat)으로 나누는 부분입니다. 큰 기울기(gradient)를 받는 가중치(weight)는 큰 수로 나뉘어 유효 보폭(effective step)이 작아집니다. 작은 기울기를 받는 가중치는 작은 수로 나뉘어 유효 보폭이 커집니다. 각 가중치가 자기만의 적응형 학습률(adaptive learning rate)을 갖습니다.
기본 하이퍼파라미터(default hyperparameter)는 lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8입니다. 대부분의 문제에서 잘 동작합니다.
학습률 스케줄(Learning rate schedule)
고정 학습률(fixed learning rate)은 타협입니다. 학습(training) 초반에는 빠르게 이동하기 위해 큰 보폭(step)이 필요합니다. 학습 후반에는 최솟값(minimum) 근처에서 미세 조정(fine-tune)하기 위해 작은 보폭이 필요합니다.
자주 쓰는 스케줄은 아래와 같습니다.
| 스케줄(Schedule) | 공식(Formula) | 사용 사례(Use case) |
|---|
| 계단식 감소(Step decay) | N epoch마다 lr = lr * factor | 단순하고 수동 제어가 쉬움 |
| 지수 감소(Exponential decay) | lr = lr_0 * decay^t | 부드럽게 줄임 |
| 코사인 어닐링(Cosine annealing) | lr = lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * t / T)) | 트랜스포머(transformer), 현대적인 학습 |
| 워밍업과 감소(Warmup + decay) | 선형으로 끌어 올린 뒤 감소 | 대형 모델, 초반 불안정성 방지 |
볼록(Convex)과 비볼록(Non-convex)
볼록 함수(convex function)는 최솟값(minimum)이 하나입니다. 경사하강법(gradient descent)은 항상 그곳을 찾습니다. f(x) = x^2 같은 이차 함수(quadratic)는 볼록(convex)입니다.
인공신경망 손실 함수(neural network loss function)는 비볼록(non-convex)입니다. 많은 지역 최솟값(local minimum), 안장점(saddle point), 평평한 영역(flat region)을 가집니다.
graph LR
subgraph Convex["볼록: 계곡 하나, 답 하나"]
direction TB
CV1["손실 큼"] --> CV2["전역 최솟값"]
end
subgraph NonConvex["비볼록: 여러 계곡과 안장점"]
direction TB
NC1["시작"] --> NC2["지역 최솟값"]
NC1 --> NC3["안장점"]
NC1 --> NC4["전역 최솟값"]
end
실제로 고차원 인공신경망(high-dimensional neural network)에서 지역 최솟값(local minimum)이 큰 문제가 되는 일은 드뭅니다. 대부분의 지역 최솟값은 전역 최솟값(global minimum)과 비슷한 손실 값을 가집니다. 진짜 장애물은 안장점(saddle point)입니다. 어떤 방향으로는 평평하고, 어떤 방향으로는 휘어진 지점입니다. 모멘텀과 미니배치에서 생기는 잡음이 여기서 빠져나오는 데 도움을 줍니다.
손실 지형 시각화(Loss landscape visualization)
손실은 모든 가중치의 함수입니다. 가중치가 100만 개인 모델의 손실 지형(loss landscape)은 1,000,001차원 공간에 놓여 있습니다. 이를 시각화하려면 가중치 공간(weight space)에서 무작위 방향 두 개를 고르고, 그 방향을 따라 손실을 그려 2D 곡면(surface)을 만듭니다.
graph TD
HL["손실이 높은 영역"] --> SP["안장점"]
HL --> LM["지역 최솟값"]
SP --> LM
SP --> GM["전역 최솟값"]
LM -.->|"얕은 장벽"| GM
style HL fill:#ff6666,color:#000
style SP fill:#ffcc66,color:#000
style LM fill:#66ccff,color:#000
style GM fill:#66ff66,color:#000
날카로운 최솟값(sharp minimum)은 일반화(generalization)가 나쁠 수 있습니다. 평평한 최솟값(flat minimum)은 일반화가 좋습니다. 모멘텀을 사용한 SGD가 최종 테스트 정확도에서 Adam을 이기는 경우가 있는 이유 중 하나입니다. SGD의 잡음(noise)이 날카로운 최솟값에 머무르는 것을 막아 줍니다.
직접 만들기
1단계: 테스트 함수(test function) 정의
로젠브록 함수(Rosenbrock function)는 고전적인 최적화 벤치마크(classic optimization benchmark)입니다. 최솟값(minimum)은 좁고 휘어진 계곡(narrow curved valley) 안의 (1, 1)에 있습니다. 찾기는 쉽지만 따라가기는 어렵습니다.
f(x, y) = (1 - x)^2 + 100 * (y - x^2)^2
def rosenbrock(params):
x, y = params
return (1 - x) ** 2 + 100 * (y - x ** 2) ** 2
def rosenbrock_gradient(params):
x, y = params
df_dx = -2 * (1 - x) + 200 * (y - x ** 2) * (-2 * x)
df_dy = 200 * (y - x ** 2)
return [df_dx, df_dy]
2단계: 기본 경사하강법(gradient descent)
class GradientDescent:
def __init__(self, lr=0.001):
self.lr = lr
def step(self, params, grads):
return [p - self.lr * g for p, g in zip(params, grads)]
3단계: 모멘텀이 있는 SGD
class SGDMomentum:
def __init__(self, lr=0.001, momentum=0.9):
self.lr = lr
self.momentum = momentum
self.velocity = None
def step(self, params, grads):
if self.velocity is None:
self.velocity = [0.0] * len(params)
self.velocity = [
self.momentum * v + g
for v, g in zip(self.velocity, grads)
]
return [p - self.lr * v for p, v in zip(params, self.velocity)]
4단계: Adam
class Adam:
def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.epsilon = epsilon
self.m = None
self.v = None
self.t = 0
def step(self, params, grads):
if self.m is None:
self.m = [0.0] * len(params)
self.v = [0.0] * len(params)
self.t += 1
self.m = [
self.beta1 * m + (1 - self.beta1) * g
for m, g in zip(self.m, grads)
]
self.v = [
self.beta2 * v + (1 - self.beta2) * g ** 2
for v, g in zip(self.v, grads)
]
m_hat = [m / (1 - self.beta1 ** self.t) for m in self.m]
v_hat = [v / (1 - self.beta2 ** self.t) for v in self.v]
return [
p - self.lr * mh / (vh ** 0.5 + self.epsilon)
for p, mh, vh in zip(params, m_hat, v_hat)
]
5단계: 실행하고 비교하기
def optimize(optimizer, func, grad_func, start, steps=5000):
params = list(start)
history = [params[:]]
for _ in range(steps):
grads = grad_func(params)
params = optimizer.step(params, grads)
history.append(params[:])
return history
start = [-1.0, 1.0]
gd_history = optimize(GradientDescent(lr=0.0005), rosenbrock, rosenbrock_gradient, start)
sgd_history = optimize(SGDMomentum(lr=0.0001, momentum=0.9), rosenbrock, rosenbrock_gradient, start)
adam_history = optimize(Adam(lr=0.01), rosenbrock, rosenbrock_gradient, start)
for name, history in [("GD", gd_history), ("SGD+M", sgd_history), ("Adam", adam_history)]:
final = history[-1]
loss = rosenbrock(final)
print(f"{name:6s} -> x={final[0]:.6f}, y={final[1]:.6f}, loss={loss:.8f}")
기대 출력(expected output)은 Adam이 가장 빠르게 수렴하는 것입니다. 모멘텀이 있는 SGD는 더 매끄러운 경로를 따릅니다. 기본 GD(vanilla GD)는 좁은 계곡을 따라 천천히 진행합니다.
사용해보기
실무에서는 PyTorch나 JAX의 옵티마이저(optimizer)를 사용합니다. 파라미터 그룹(parameter group), 가중치 감쇠(weight decay), 기울기 클리핑(gradient clipping), GPU 가속을 처리해 줍니다.
import torch
model = torch.nn.Linear(784, 10)
sgd = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
adam = torch.optim.Adam(model.parameters(), lr=0.001)
adamw = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(adam, T_max=100)
경험칙(rule of thumb):
- 먼저 Adam(
lr=0.001)으로 시작합니다. 대부분의 문제에서 별도 튜닝 없이도 동작합니다.
- 최고의 최종 정확도가 필요하고 튜닝할 여유가 있으면 모멘텀이 있는 SGD(
lr=0.01, momentum=0.9)로 전환합니다.
- 트랜스포머(transformer)에는 AdamW, 즉 분리된 가중치 감쇠(decoupled weight decay)가 적용된 Adam을 사용합니다.
- 몇 에폭(epoch)보다 긴 학습 실행에는 항상 학습률 스케줄(learning rate schedule)을 사용합니다.
- 학습이 불안정하면 학습률을 낮춥니다. 학습이 너무 느리면 학습률을 높입니다.
산출물 만들기
이 lesson의 검수 대상은 아래 두 가지입니다.
code/optimizers.py: 경사하강법, 모멘텀이 있는 SGD, Adam을 비교하는 실행 가능한 예제
outputs/prompt-optimizer-guide.md: 문제 상황에 맞는 옵티마이저를 고르도록 안내하는 가이드 프롬프트
여기서 만든 옵티마이저 클래스는 Phase 3에서 인공신경망을 처음부터 학습할 때 다시 등장합니다.
연습문제
- (쉬움) 학습률 탐색(Learning rate sweep). 로젠브록 함수(Rosenbrock function)에서 기본 경사하강법(vanilla gradient descent)을 학습률
[0.0001, 0.0005, 0.001, 0.005, 0.01]로 실행합니다. 5000 단계(step) 뒤 최종 손실(final loss)을 그리거나 출력합니다. 수렴하는 가장 큰 학습률을 찾습니다.
- (중간) 모멘텀 비교(Momentum comparison). 로젠브록 함수에서 모멘텀 값
[0.0, 0.5, 0.9, 0.99]로 SGD를 실행합니다. 모든 단계의 손실을 추적합니다. 어떤 모멘텀이 가장 빠르게 수렴하나요? 어떤 값이 최솟값을 지나치는(overshoot) 모습을 보이나요?
- (중간) 안장점 탈출(Saddle point escape).
f(x, y) = x^2 - y^2를 정의합니다. 원점에 안장점(saddle point)이 있습니다. (0.01, 0.01)에서 시작해 기본 GD, 모멘텀이 있는 SGD, Adam을 비교합니다. 어떤 옵티마이저가 안장점에서 빠져나오나요?
- (어려움) 학습률 감소(Learning rate decay) 구현.
GradientDescent 클래스에 지수 감소 스케줄(exponential decay schedule) lr = lr_0 * 0.999^step을 추가합니다. 로젠브록 함수에서 학습률 감소가 있을 때와 없을 때의 수렴 양상을 비교합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 경사하강법(Gradient descent) | "비탈을 따라 내려가기" | 가중치(weight)에서 학습률(learning rate)을 곱한 기울기(gradient)를 빼서 갱신하는 가장 기본적인 옵티마이저(optimizer)이다. |
| 학습률(Learning rate) | "보폭(step size)" | 한 번의 갱신이 가중치를 얼마나 멀리 움직일지 정하는 스칼라이다. 너무 크면 발산(diverge)하고, 너무 작으면 연산을 낭비한다. |
| 모멘텀(Momentum) | "계속 굴러가게 만들기" | 과거 기울기(past gradient)를 속도 벡터(velocity vector)에 누적한다. 진동(oscillation)을 줄이고 일관된 방향에서는 가속한다. |
| SGD | "무작위 샘플링(random sampling)" | 전체 데이터셋(dataset) 대신 무작위 부분집합에서 기울기를 계산하는 확률적 경사하강법(stochastic gradient descent). 실무에서는 거의 항상 미니배치(mini-batch) SGD를 가리킨다. |
| 미니배치(Mini-batch) | "데이터 한 묶음" | 기울기를 추정(estimate)하는 데 쓰는 작은 학습 데이터 부분집합이다. 보통 32~256개 샘플로 구성한다. |
| Adam | "기본 옵티마이저" | 적응형 모멘트 추정(Adaptive Moment Estimation). 기울기와 제곱 기울기의 이동 평균(running average)을 가중치마다 추적해 각 가중치에 적응형 학습률(adaptive learning rate)을 부여한다. |
| 편향 보정(Bias correction) | "초기 시작 보정" | Adam의 1차·2차 모멘트는 0에서 시작한다. 초기 단계에서 이를 보정하기 위해 (1 - beta^t)로 나눠 준다. |
| 학습률 스케줄(Learning rate schedule) | "시간에 따라 학습률 바꾸기" | 학습 도중 학습률을 조절하는 함수이다. 초반에는 큰 보폭, 후반에는 작은 보폭을 사용한다. |
| 볼록 함수(Convex function) | "계곡 하나" | 모든 지역 최솟값(local minimum)이 곧 전역 최솟값(global minimum)인 함수이다. 경사하강법으로 언제나 찾을 수 있다. 인공신경망 손실은 볼록이 아니다. |
| 안장점(Saddle point) | "평평하지만 최솟값은 아닌 지점" | 기울기는 0이지만 어떤 방향으로는 최솟값, 다른 방향으로는 최댓값이 되는 지점이다. 고차원에서 흔하게 나타난다. |
| 손실 지형(Loss landscape) | "지형(terrain)" | 가중치 공간(weight space) 위에 그린 손실 함수이다. 보통 무작위 방향 두 개로 단면을 잘라(slice) 시각화한다. |
| 수렴(Convergence) | "도달했음" | 옵티마이저가 더 움직여도 손실이 의미 있게 줄어들지 않는 지점에 다다른 상태이다. |
더 읽을거리