[Lucid] 다양한 LR Scheduler 구현

안암동컴맹·2025년 12월 13일

Lucid Development

목록 보기
15/20
post-thumbnail

🎛️ 다양한 LR Scheduler 구현

이번 개발 일지는 Lucid의 학습률 스케줄러를 총망라해, 수식과 코드가 어떻게 1:1로 대응되는지 긴 호흡으로 풀어본다. 옵티마이저가 파라미터를 갱신한다면, 스케줄러는 시간 축에서 학습률 궤적을 설계한다. PyTorch와 동일한 사용성을 목표로 하되, Lucid는 최소한의 상태로 직렬화·복원을 단순화하고, 파라미터 그룹/디바이스와 충돌 없이 동작하도록 설계되었다.


🪢 공통 베이스 – LRScheduler와 상태 직렬화

모든 스케줄러는 LRScheduler를 상속한다(lucid/optim/lr_scheduler/_base.py). 핵심 필드:

  • base_lrs: 옵티마이저 각 파라미터 그룹의 초기 lr 스냅샷
  • last_epoch: 마지막으로 적용한 에폭(혹은 호출) 번호
  • _last_lr: 직전 스텝에서 실제로 쓴 lr 리스트
  • _step_count: step() 호출 횟수

상태 직렬화는 다음 딕셔너리로 표현된다:

state_dict={last_epoch,base_lrs,_step_count,_last_lr,_group_count}.\text{state\_dict} = \{\text{last\_epoch},\, \text{base\_lrs},\, \_ \text{step\_count},\, \_ \text{last\_lr},\, \_ \text{group\_count}\}.

load_state_dict는 그룹 수가 다르면 즉시 예외를 던져 잘못된 복원을 차단한다. 즉, 모델·옵티마이저 구성이 바뀌면 스케줄러도 함께 재생성하는 것이 안전하다. step(epoch=None)은 호출 시점을 기준으로 last_epoch를 증가시키거나 전달된 epoch로 설정하고, get_lr()가 반환한 리스트를 각 그룹의 lr에 덮어쓴다.

파라미터 그룹과 스케줄 동기화

그룹별로 다른 lr을 쓰고 싶다면 옵티마이저를 만들 때 그룹 dict에 lr을 명시해야 한다. 스케줄러는 base_lrs에 저장된 값에 동일 factor를 곱하거나, Noam처럼 절대 lr을 반환하는 경우 모든 그룹에 같은 값을 적용한다. 그룹 수가 불일치하면 ValueError가 발생하므로, 체크포인트 재개 시 동일한 그룹 구성을 유지해야 한다.

🌀 Step 계열 – StepLR, MultiStepLR

수식: 일정 주기마다 lrγlr\text{lr} \leftarrow \gamma \cdot \text{lr}.

구현: lucid/optim/lr_scheduler/_schedulers.py

  • StepLR: factor=γt/step_size\text{factor} = \gamma^{\lfloor t / \text{step\_size} \rfloor}, 새 lr은 base_lr×factor\text{base\_lr} \times \text{factor}.
  • MultiStepLR: 마일스톤 MM에 대해 factor=γmM1[tm]\text{factor} = \gamma^{\sum_{m \in M} \mathbb{1}[t \ge m]}.
factor = self.gamma ** (self.last_epoch // self.step_size)               # StepLR
factor = self.gamma ** sum(self.last_epoch >= m for m in milestones)     # MultiStepLR
new_lrs = [base_lr * factor for base_lr in self.base_lrs]

언제 쓰나?

감쇠 시점을 명확히 알고 있을 때(예: 30/60/90 에폭). 이미지 분류 전통 설정에서 자주 쓰인다. step_size가 너무 짧으면 과도한 감쇠로 언더피팅, 너무 길면 수렴이 느려질 수 있으니 에폭과 데이터셋 크기에 맞춰 조정한다.

사용 예

optimizer = lucid.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
scheduler = lucid.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)

for epoch in range(90):
    train_one_epoch(...)
    scheduler.step()

☄️ ExponentialLR – 기하급수 감쇠

수식: lrt=base_lrγt\text{lr}_t = \text{base\_lr} \cdot \gamma^{t}.

구현: lucid/optim/lr_scheduler/_schedulers.py

factor = self.gamma ** self.last_epoch로 계산하고 그룹별 lr을 일괄 갱신한다. γ>0\gamma > 0 검증으로 음수/0 학습률을 차단한다. 에폭이 많을수록 지수적 감쇠가 빠르게 진행되므로, 작은 γ\gamma일수록 중반 이후 학습률이 급격히 떨어진다는 점을 염두에 둔다.

사용 예

optimizer = lucid.optim.Adam(model.parameters(), lr=1e-3)
scheduler = lucid.optim.lr_scheduler.ExponentialLR(optimizer, gamma=0.95)

for epoch in range(50):
    train_one_epoch(...)
    scheduler.step()

🌗 CosineAnnealingLR – 코사인 진폭 감소

수식:

lrt=ηmin+(base_lrηmin)1+cos(πt/Tmax)2.\text{lr}_t = \eta_{\min} + (\text{base\_lr} - \eta_{\min}) \cdot \frac{1 + \cos(\pi t / T_{\max})}{2}.

구현: lucid/optim/lr_scheduler/_schedulers.py

T_max 동안 절반 주기의 코사인으로 부드럽게 감소하며, ηmin\eta_{\min}에 닿는다. T_max <= 0이면 예외로 막는다. 코사인 특성상 초반에는 완만하게 유지되다가 후반에 급격히 감소해, 워밍업 없이도 후반 정제(fine-tuning) 효과를 얻는다.

t = self.last_epoch
lr = eta_min + (base_lr - eta_min) * (1 + math.cos(math.pi * t / T_max)) / 2

추가 메모: 워밍업 리스타트를 지원하지 않으므로, 여러 주기를 원하면 스케줄러를 재생성하거나 T_max를 잘게 나누어 사용한다. ηmin\eta_{\min}을 0보다 크게 두면 과도한 감쇠로 학습이 멈추는 것을 방지할 수 있다.

🚥 ReduceLROnPlateau – 플래토 감지 후 감소

수식 개요: 모니터 지표 mtm_t가 개선되지 않으면 lrmax(lrfactor,min_lr)\text{lr} \leftarrow \max(\text{lr} \cdot \text{factor}, \text{min\_lr}).

구현: lucid/optim/lr_scheduler/_schedulers.py

  • modemin이면 지표가 감소해야 개선, max면 증가해야 개선.
  • threshold_moderel이면 상대 임계값(비율), abs면 절대 임계값.
  • patience를 넘겨 나쁜 epoch가 누적되면 _reduce_lr() 실행.
  • cooldown으로 연속 감소를 제한, eps로 의미 없는 변화는 무시.
if self.num_bad_epochs > self.patience:
    new_lr = max(group["lr"] * self.factor, self.min_lr)
    if group["lr"] - new_lr > self.eps:
        group["lr"] = new_lr

사용 예

optimizer = lucid.optim.AdamW(model.parameters(), lr=3e-4)
scheduler = lucid.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer, mode="min", factor=0.2, patience=3, cooldown=1, min_lr=1e-6
)
for epoch in range(30):
    train_loss = train_one_epoch(...)
    val_loss = evaluate(...)
    scheduler.step(val_loss)  # 지표 필수

플래토 스케줄러만 step(metrics) 인자를 요구하며, get_lr()_last_lr를 그대로 반환한다는 점이 다른 스케줄러와 다르다.

🎢 CyclicLR – 삼각/지수 모드 주기

수식: base_lr와 max_lr 사이를 주기적으로 왕복. 위상 xx를 사용해

lr=base+(maxbase)max(0,1x)scale(cycle).\text{lr} = \text{base} + (\text{max}-\text{base}) \cdot \max(0, 1-x) \cdot \text{scale}(cycle).

구현: lucid/optim/lr_scheduler/_schedulers.py

cycle = self.last_epoch // self.total_size
x = abs(self.last_epoch / self.step_size_up - 2 * cycle - 1)

if mode == "triangular":
    scale_factor = 1.0
elif mode == "triangular2":
    scale_factor = 1 / (2**cycle)
elif mode == "exp_range":
    scale_factor = self.gamma**self.last_epoch
else:
    scale_factor = self.scale_fn(cycle) if self.scale_fn else 1.0

lr = base_lr + (max_lr - base_lr) * max(0, 1 - x) * scale_factor

사용 메모:

  • cycle_momentum은 자리만 있지만 모멘텀 조정은 미구현 → 학습률만 변한다.
  • step_size_up/down을 다르게 설정하면 비대칭 삼각형을 만들 수 있다(짧게 올리고 길게 내리기 등).
  • exp_range 모드에서 gamma < 1이면 반복될수록 피크가 줄어 탐색 강도가 감소한다.

🛰️ NoamScheduler – Transformer 워밍업 스케줄

수식 (Noam):

lr(t)=factordmodel0.5min(t0.5,  twarmup1.5).\text{lr}(t) = \text{factor} \cdot d_{\text{model}}^{-0.5} \cdot \min\left(t^{-0.5},\; t \cdot \text{warmup}^{-1.5}\right).

구현: lucid/optim/lr_scheduler/_schedulers.py

  • model_size, warmup_steps, factor로 결정.
  • step_num = max(self.last_epoch, 1)로 0-division 방지.
  • 절대 lr을 반환하므로 base_lr를 무시하고 모든 그룹에 동일 lr을 적용한다.
step_num = max(self.last_epoch, 1)
scale = self.factor * (self.model_size ** -0.5)

warmup_term = step_num * (self.warmup_steps ** -1.5)
decay_term = step_num ** -0.5

lr_factor = scale * min(decay_term, warmup_term)
return [lr_factor for _ in self.base_lrs]

추가 메모

warmup 구간에서는 선형 증가, 이후 t0.5t^{-0.5}로 감소해 큰 모델에서도 안정적으로 학습을 시작할 수 있다. base_lr는 관습적으로 1.0으로 두어도 무방하며, factor로 전체 스케일을 조정한다.


🧪 사용 예 – 스케줄러 장착 학습 루프

model = MyNet().to("gpu")
optimizer = lucid.optim.Adam(model.parameters(), lr=1e-3)
scheduler = lucid.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=50, eta_min=1e-5
)

for epoch in range(50):
    for inputs, targets in loader:
        inputs, targets = inputs.to("gpu"), targets.to("gpu")
        optimizer.zero_grad()

        out = model(inputs)
        loss = F.cross_entropy(out, targets)
        loss.eval()

        loss.backward()
        optimizer.step()
        
    scheduler.step()  # ReduceLROnPlateau만 metrics 필요

플래토 스케줄러를 쓸 때는 scheduler.step(val_loss)처럼 지표를 넘겨야 하며, 다른 스케줄러는 step() 호출만으로 충분하다. CyclicLR/Noam처럼 스텝 단위로 변하는 스케줄은 배치마다 step()을 호출하는 패턴을 택할 수 있지만, 호출 횟수와 의미(에폭/스텝)를 일관되게 유지해야 한다.

🧭 디바이스·직렬화·재현성 체크리스트

  • 디바이스: 스케줄러는 lr 스칼라만 조정하므로 장치에 구애받지 않는다. 다만 모델·옵티마이저를 원하는 디바이스로 옮긴 뒤 스케줄러를 생성하면 그룹 검증이 확실하다.
  • state_dict: 스케줄러 상태와 옵티마이저 상태를 함께 저장/로드해야 동일한 lr 궤적을 재현할 수 있다. 그룹 수가 바뀌면 로드 시 예외가 난다.
  • step 위치: 대부분 에폭 끝에서 호출하지만, 스텝 단위 변형(Cyclic, Noam)에서는 배치마다 호출할 수도 있다. Lucid는 last_epoch를 호출 횟수로 해석하므로 한 방식으로 통일해야 한다.

🧠 정리

  • LRScheduler 베이스: 파라미터 그룹 검증과 상태 직렬화로 재현성을 확보한다.
  • Step/MultiStep/Exponential: 계단식·기하식 감쇠, γ\gamma와 주기로 단순 제어.
  • CosineAnnealing: 완만-급격 감소 패턴, ηmin\eta_{\min}로 바닥 설정.
  • ReduceLROnPlateau: 지표 개선 부진 시에만 감소, patience/cooldown/eps로 노이즈를 억제.
  • CyclicLR: 주기적 리셋으로 탐색 강화, 삼각·지수 모드 제공.
  • NoamScheduler: 워밍업 후 t0.5t^{-0.5} 감소, base_lr 무시하고 절대 lr을 반환.

공통적으로 모델을 원하는 디바이스로 옮긴 뒤 옵티마이저를 만들고, 그 다음 스케줄러를 연결하는 순서를 지키면 디바이스 불일치를 피할 수 있다. 수식을 그대로 옮긴 구현 덕분에 파라미터만 맞추면 바로 실험에 투입할 수 있으며, 체크포인트 저장/복원도 간결하다.

profile
Korea Univ. Computer Science & Engineering

0개의 댓글