
이번 개발 일지는 Lucid의 학습률 스케줄러를 총망라해, 수식과 코드가 어떻게 1:1로 대응되는지 긴 호흡으로 풀어본다. 옵티마이저가 파라미터를 갱신한다면, 스케줄러는 시간 축에서 학습률 궤적을 설계한다. PyTorch와 동일한 사용성을 목표로 하되, Lucid는 최소한의 상태로 직렬화·복원을 단순화하고, 파라미터 그룹/디바이스와 충돌 없이 동작하도록 설계되었다.
모든 스케줄러는 LRScheduler를 상속한다(lucid/optim/lr_scheduler/_base.py). 핵심 필드:
base_lrs: 옵티마이저 각 파라미터 그룹의 초기 lr 스냅샷last_epoch: 마지막으로 적용한 에폭(혹은 호출) 번호_last_lr: 직전 스텝에서 실제로 쓴 lr 리스트_step_count: step() 호출 횟수상태 직렬화는 다음 딕셔너리로 표현된다:
load_state_dict는 그룹 수가 다르면 즉시 예외를 던져 잘못된 복원을 차단한다. 즉, 모델·옵티마이저 구성이 바뀌면 스케줄러도 함께 재생성하는 것이 안전하다. step(epoch=None)은 호출 시점을 기준으로 last_epoch를 증가시키거나 전달된 epoch로 설정하고, get_lr()가 반환한 리스트를 각 그룹의 lr에 덮어쓴다.
그룹별로 다른 lr을 쓰고 싶다면 옵티마이저를 만들 때 그룹 dict에 lr을 명시해야 한다. 스케줄러는 base_lrs에 저장된 값에 동일 factor를 곱하거나, Noam처럼 절대 lr을 반환하는 경우 모든 그룹에 같은 값을 적용한다. 그룹 수가 불일치하면 ValueError가 발생하므로, 체크포인트 재개 시 동일한 그룹 구성을 유지해야 한다.
수식: 일정 주기마다 .
구현: lucid/optim/lr_scheduler/_schedulers.py
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()
수식: .
구현: lucid/optim/lr_scheduler/_schedulers.py
factor = self.gamma ** self.last_epoch로 계산하고 그룹별 lr을 일괄 갱신한다. 검증으로 음수/0 학습률을 차단한다. 에폭이 많을수록 지수적 감쇠가 빠르게 진행되므로, 작은 일수록 중반 이후 학습률이 급격히 떨어진다는 점을 염두에 둔다.
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()
수식:
구현: lucid/optim/lr_scheduler/_schedulers.py
T_max 동안 절반 주기의 코사인으로 부드럽게 감소하며, 에 닿는다. 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를 잘게 나누어 사용한다. 을 0보다 크게 두면 과도한 감쇠로 학습이 멈추는 것을 방지할 수 있다.
수식 개요: 모니터 지표 가 개선되지 않으면 .
구현: lucid/optim/lr_scheduler/_schedulers.py
mode가 min이면 지표가 감소해야 개선, max면 증가해야 개선.threshold_mode가 rel이면 상대 임계값(비율), 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를 그대로 반환한다는 점이 다른 스케줄러와 다르다.
수식: base_lr와 max_lr 사이를 주기적으로 왕복. 위상 를 사용해
구현: 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을 다르게 설정하면 비대칭 삼각형을 만들 수 있다(짧게 올리고 길게 내리기 등).gamma < 1이면 반복될수록 피크가 줄어 탐색 강도가 감소한다.수식 (Noam):
구현: lucid/optim/lr_scheduler/_schedulers.py
model_size, warmup_steps, factor로 결정.step_num = max(self.last_epoch, 1)로 0-division 방지.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 구간에서는 선형 증가, 이후 로 감소해 큰 모델에서도 안정적으로 학습을 시작할 수 있다. 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()을 호출하는 패턴을 택할 수 있지만, 호출 횟수와 의미(에폭/스텝)를 일관되게 유지해야 한다.
last_epoch를 호출 횟수로 해석하므로 한 방식으로 통일해야 한다.patience/cooldown/eps로 노이즈를 억제.공통적으로 모델을 원하는 디바이스로 옮긴 뒤 옵티마이저를 만들고, 그 다음 스케줄러를 연결하는 순서를 지키면 디바이스 불일치를 피할 수 있다. 수식을 그대로 옮긴 구현 덕분에 파라미터만 맞추면 바로 실험에 투입할 수 있으며, 체크포인트 저장/복원도 간결하다.