최적화 — 경사하강법 계열

인공신경망(neural network)을 학습한다는 것은 계곡(valley)의 바닥을 찾는 일과 다르지 않습니다.

유형: Build 언어: Python 선수 지식: Phase 1, Lessons 04-05 (도함수와 기울기, Derivatives & Gradients) 예상 시간: 약 75분

학습 목표

  • 기본 경사하강법(vanilla gradient descent), 모멘텀(momentum)을 사용한 확률적 경사하강법(SGD), Adam을 처음부터 구현합니다.
  • 로젠브록 함수(Rosenbrock function)에서 옵티마이저 수렴(optimizer convergence)을 비교하고 Adam이 가중치(weight)마다 적응형 학습률(adaptive learning rate)을 쓰는 이유를 설명합니다.
  • 볼록 손실 지형(convex loss landscape)과 비볼록 손실 지형(non-convex loss landscape)을 구분하고, 고차원(high dimension)에서 안장점(saddle point)이 하는 역할을 설명합니다.
  • 학습 안정성(training stability)을 위해 계단식 감소(step decay), 코사인 어닐링(cosine annealing), 워밍업(warmup) 같은 학습률 스케줄(learning rate schedule)을 설정합니다.

문제

손실 함수(loss function)가 있습니다. 이 함수는 모델(model)이 얼마나 틀렸는지 알려 줍니다. 기울기(gradient)도 있습니다. 기울기는 어느 방향으로 가면 손실(loss)이 더 나빠지는지 알려 줍니다. 이제 필요한 것은 비탈을 따라 아래로(downhill) 걸어 내려가는 전략입니다.

가장 단순한 접근은 기울기(gradient)의 반대 방향으로 이동하는 것입니다. 학습률(learning rate)이라는 숫자로 보폭(step size)을 조절합니다. 그리고 반복합니다. 이것이 경사하강법(gradient descent)이고, 실제로 동작합니다. 하지만 "동작한다"에는 단서가 있습니다. 학습률이 너무 크면 계곡(valley)을 지나쳐 벽 사이에서 튕깁니다. 너무 작으면 필요 없는 수천 걸음(step) 동안 답을 향해 기어갑니다. 안장점(saddle point)을 만나면 최솟값(minimum)을 찾지 않았는데도 멈춥니다.

딥러닝(deep learning)의 모든 옵티마이저(optimizer)는 같은 질문에 대한 답입니다. 어떻게 더 빠르고 안정적으로 계곡의 바닥에 도달할 것인가?

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.인공신경망(neural network)을 학습(training)하는 맥락에서 최적화(optimization)는 무엇을 의미하나요?

2.경사하강법(gradient descent) 중 학습률(learning rate)이 너무 크면 어떤 일이 생기나요?

0/2 답변 완료

개념

최적화(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. 1차 모멘트(first moment) m: 기울기(gradient)의 이동 평균(running average)입니다. 모멘텀(momentum)과 비슷합니다.
  2. 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에서 인공신경망을 처음부터 학습할 때 다시 등장합니다.

연습문제

  1. (쉬움) 학습률 탐색(Learning rate sweep). 로젠브록 함수(Rosenbrock function)에서 기본 경사하강법(vanilla gradient descent)을 학습률 [0.0001, 0.0005, 0.001, 0.005, 0.01]로 실행합니다. 5000 단계(step) 뒤 최종 손실(final loss)을 그리거나 출력합니다. 수렴하는 가장 큰 학습률을 찾습니다.
  2. (중간) 모멘텀 비교(Momentum comparison). 로젠브록 함수에서 모멘텀 값 [0.0, 0.5, 0.9, 0.99]로 SGD를 실행합니다. 모든 단계의 손실을 추적합니다. 어떤 모멘텀이 가장 빠르게 수렴하나요? 어떤 값이 최솟값을 지나치는(overshoot) 모습을 보이나요?
  3. (중간) 안장점 탈출(Saddle point escape). f(x, y) = x^2 - y^2를 정의합니다. 원점에 안장점(saddle point)이 있습니다. (0.01, 0.01)에서 시작해 기본 GD, 모멘텀이 있는 SGD, Adam을 비교합니다. 어떤 옵티마이저가 안장점에서 빠져나오나요?
  4. (어려움) 학습률 감소(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)"도달했음"옵티마이저가 더 움직여도 손실이 의미 있게 줄어들지 않는 지점에 다다른 상태이다.

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

optimizers
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

prompt-optimizer-guide

Guides the user through choosing the right optimizer for their specific machine learning problem

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.Adam은 기본 경사하강법(vanilla gradient descent)과 어떻게 다른가요?

2.미니배치 SGD(mini-batch SGD)의 잡음(noise)이 단순한 방해가 아니라 도움이 된다고 보는 이유는 무엇인가요?

3.코사인 어닐링 학습률 스케줄(Cosine annealing learning rate schedule)은 무엇을 하나요?

0/3 답변 완료