본문 바로가기

딥러닝/딥러닝 기초 개념

경사하강법과 최적화 (Gradient Descent, Adam, SGD)

들어가며

역전파로 각 가중치의 기울기를 구했다. 이제 그 기울기를 이용해서 실제로 가중치를 어떻게 업데이트할지가 문제다. 단순히 기울기 반대 방향으로 이동하는 기본 경사하강법부터, 실무에서 주로 쓰이는 Adam까지 각 방법의 아이디어와 차이점을 정리해둔다.


경사하강법 (Gradient Descent)

손실 함수의 최솟값을 향해 기울기 반대 방향으로 조금씩 이동하는 최적화 방법이다.

w = w - lr * ∂L/∂w

w  : 가중치
lr : 학습률 (learning rate)
∂L/∂w : 손실에 대한 가중치의 기울기
import numpy as np

# 간단한 예시: f(x) = x²의 최솟값 찾기
def f(x): return x ** 2
def df(x): return 2 * x

x = 10.0
lr = 0.1

print(f"초기값: x={x:.4f}, f(x)={f(x):.4f}")

for step in range(30):
    grad = df(x)
    x = x - lr * grad

    if (step + 1) % 5 == 0:
        print(f"step {step+1:2d}: x={x:.6f}, f(x)={f(x):.8f}")

# step  5: f(x)=10.73...
# step 10: f(x)=1.152...
# step 20: f(x)=0.013...
# step 30: f(x)=0.000... → 0에 수렴

학습률의 중요성

# 학습률이 너무 크면 발산
x = 10.0
lr_too_big = 1.1
for step in range(5):
    x = x - lr_too_big * df(x)
    print(f"step {step+1}: x={x:.4f}")  # 점점 커지며 발산

# 학습률이 너무 작으면 수렴이 느림
x = 10.0
lr_too_small = 0.001
for step in range(30):
    x = x - lr_too_small * df(x)
print(f"30step 후: x={x:.4f}")  # 아직 최솟값에 멀리 있음
학습률 설정 기준:
너무 크면  : 손실이 들쭉날쭉하거나 발산 (NaN)
너무 작으면: 학습이 너무 느림
적당하면  : 안정적으로 손실이 줄어듦

일반적인 시작값: 0.001 ~ 0.01
Adam 사용 시  : 0.001이 좋은 시작점

배치 경사하강법 종류

데이터를 얼마나 사용해서 기울기를 계산하느냐에 따라 세 가지로 나뉜다.

1. Batch Gradient Descent (BGD)

전체 데이터로 기울기를 계산한다.

def batch_gd(X, y, W, b, lr, epochs):
    n = len(X)
    for epoch in range(epochs):
        y_pred = X @ W + b
        loss = np.mean((y_pred - y) ** 2)

        # 전체 데이터로 기울기 계산
        dW = (2/n) * X.T @ (y_pred - y)
        db = (2/n) * np.sum(y_pred - y)

        W -= lr * dW
        b -= lr * db

# 장점: 안정적인 수렴, 정확한 기울기
# 단점: 데이터가 크면 너무 느림, 메모리 부족

2. Stochastic Gradient Descent (SGD)

데이터 1개씩 기울기를 계산한다.

def sgd(X, y, W, b, lr, epochs):
    n = len(X)
    for epoch in range(epochs):
        indices = np.random.permutation(n)
        for i in indices:
            xi = X[i:i+1]
            yi = y[i:i+1]
            y_pred = xi @ W + b

            # 1개 샘플로 기울기 계산
            dW = 2 * xi.T @ (y_pred - yi)
            db = 2 * np.sum(y_pred - yi)
            W -= lr * dW
            b -= lr * db

# 장점: 빠른 업데이트, 지역 최솟값 탈출 가능
# 단점: 불안정한 수렴, 노이즈가 많음

3. Mini-Batch Gradient Descent

실무에서 거의 항상 쓰이는 방식이다. 전체 데이터를 작은 배치로 나눠서 계산한다.

def mini_batch_gd(X, y, W, b, lr, epochs, batch_size=32):
    n = len(X)
    for epoch in range(epochs):
        indices = np.random.permutation(n)
        X_shuffled = X[indices]
        y_shuffled = y[indices]

        for start in range(0, n, batch_size):
            X_batch = X_shuffled[start:start+batch_size]
            y_batch = y_shuffled[start:start+batch_size]
            batch_n = len(X_batch)

            y_pred = X_batch @ W + b
            dW = (2/batch_n) * X_batch.T @ (y_pred - y_batch)
            db = (2/batch_n) * np.sum(y_pred - y_batch)

            W -= lr * dW
            b -= lr * db

# 장점: BGD와 SGD의 절충안, GPU 병렬 연산 효율적 활용
# 배치 크기: 32, 64, 128, 256이 일반적
# 클수록 안정적, 작을수록 빠르고 일반화 잘 됨

고급 최적화 알고리즘

Momentum

언덕을 굴러 내려오는 공처럼 이전 방향의 관성을 유지한다.

class SGDMomentum:
    def __init__(self, lr=0.01, momentum=0.9):
        self.lr = lr
        self.momentum = momentum
        self.velocity = {}

    def update(self, params, grads):
        for key in params:
            if key not in self.velocity:
                self.velocity[key] = np.zeros_like(params[key])

            # 이전 속도를 유지하면서 새 기울기 추가
            self.velocity[key] = (self.momentum * self.velocity[key]
                                 - self.lr * grads[key])
            params[key] += self.velocity[key]
        return params

# momentum = 0.9가 일반적인 값
# 기울기가 일정 방향이면 속도가 점점 빨라짐

AdaGrad

각 파라미터마다 학습률을 다르게 조정한다. 많이 업데이트된 파라미터는 학습률을 줄인다.

class AdaGrad:
    def __init__(self, lr=0.01, epsilon=1e-8):
        self.lr = lr
        self.epsilon = epsilon
        self.h = {}

    def update(self, params, grads):
        for key in params:
            if key not in self.h:
                self.h[key] = np.zeros_like(params[key])

            self.h[key] += grads[key] ** 2
            params[key] -= self.lr * grads[key] / (np.sqrt(self.h[key]) + self.epsilon)
        return params

# 자주 등장하는 특성 → 학습률 감소
# 드물게 등장하는 특성 → 학습률 유지
# 단점: 학습이 진행될수록 학습률이 너무 작아져 학습 중단

RMSProp

AdaGrad의 학습률이 0으로 수렴하는 문제를 해결한다.

class RMSProp:
    def __init__(self, lr=0.001, rho=0.9, epsilon=1e-8):
        self.lr = lr
        self.rho = rho
        self.epsilon = epsilon
        self.h = {}

    def update(self, params, grads):
        for key in params:
            if key not in self.h:
                self.h[key] = np.zeros_like(params[key])

            # 지수 이동 평균으로 기울기 제곱 추적
            self.h[key] = self.rho * self.h[key] + (1 - self.rho) * grads[key] ** 2
            params[key] -= self.lr * grads[key] / (np.sqrt(self.h[key]) + self.epsilon)
        return params

Adam (Adaptive Moment Estimation)

현재 가장 널리 사용되는 최적화 알고리즘이다. Momentum + RMSProp의 장점을 결합했다.

class Adam:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
        self.lr = lr
        self.beta1 = beta1   # 1차 모멘트 감쇠율
        self.beta2 = beta2   # 2차 모멘트 감쇠율
        self.epsilon = epsilon
        self.m = {}          # 1차 모멘트 (평균)
        self.v = {}          # 2차 모멘트 (분산)
        self.t = 0

    def update(self, params, grads):
        self.t += 1
        for key in params:
            if key not in self.m:
                self.m[key] = np.zeros_like(params[key])
                self.v[key] = np.zeros_like(params[key])

            # 1차 모멘트: 기울기의 지수 이동 평균
            self.m[key] = self.beta1 * self.m[key] + (1 - self.beta1) * grads[key]
            # 2차 모멘트: 기울기 제곱의 지수 이동 평균
            self.v[key] = self.beta2 * self.v[key] + (1 - self.beta2) * grads[key] ** 2

            # 편향 보정 (초반 스텝에서 0에 편향되는 문제 수정)
            m_hat = self.m[key] / (1 - self.beta1 ** self.t)
            v_hat = self.v[key] / (1 - self.beta2 ** self.t)

            params[key] -= self.lr * m_hat / (np.sqrt(v_hat) + self.epsilon)
        return params

# 기본값: lr=0.001, beta1=0.9, beta2=0.999
# 대부분의 경우 좋은 시작점

PyTorch에서 옵티마이저 사용

import torch
import torch.nn as nn

model = nn.Sequential(
    nn.Linear(10, 64),
    nn.ReLU(),
    nn.Linear(64, 32),
    nn.ReLU(),
    nn.Linear(32, 1)
)

# 다양한 옵티마이저
optimizer_sgd   = torch.optim.SGD(model.parameters(), lr=0.01)
optimizer_sgd_m = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
optimizer_adam  = torch.optim.Adam(model.parameters(), lr=0.001)
optimizer_adamw = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
# AdamW: Adam + L2 정규화 → Transformer 계열에서 주로 사용

학습률 스케줄러

학습이 진행됨에 따라 학습률을 조정한다.

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# 1. StepLR: 일정 에폭마다 학습률을 gamma배 감소
scheduler = torch.optim.lr_scheduler.StepLR(
    optimizer, step_size=30, gamma=0.1
)  # 30 에폭마다 lr = lr * 0.1

# 2. CosineAnnealingLR: 코사인 함수로 학습률 감소
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=100
)

# 3. ReduceLROnPlateau: 성능 개선 없으면 학습률 감소
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode='min', factor=0.1, patience=10
)

# 학습 루프에서 사용
for epoch in range(100):
    train_loss = train_one_epoch(model, optimizer)
    val_loss = validate(model)

    scheduler.step(val_loss)

    current_lr = optimizer.param_groups[0]['lr']
    print(f"Epoch {epoch}: lr={current_lr:.6f}, loss={train_loss:.4f}")

실무에서 옵티마이저 선택

기본 선택:
일반적인 경우     → Adam (lr=0.001)
Transformer 계열  → AdamW (lr=1e-4 ~ 5e-4, weight_decay=0.01)
컴퓨터 비전      → SGD + Momentum (lr=0.01, momentum=0.9)
Fine-tuning       → Adam 또는 AdamW (lr: 1e-5 ~ 1e-4)

학습률 선택 팁:
- Adam: 0.001 (거의 항상 좋은 시작점)
- SGD: 0.01 ~ 0.1
- 너무 크면 loss가 NaN    → lr을 10배 줄임
- 너무 작으면 loss가 안 줄어듦 → lr을 10배 키움

정리

옵티마이저 특징 사용처
SGD 단순, 느림 기본 학습 이해
SGD + Momentum 관성으로 빠른 수렴 컴퓨터 비전
AdaGrad 적응적 학습률 희소 데이터, NLP
RMSProp AdaGrad 개선 RNN
Adam Momentum + RMSProp 가장 범용적
AdamW Adam + L2 정규화 Transformer, BERT, GPT