
Lucid의 Optimizer와 LR Scheduler는 PyTorch를 벤치마킹해 동일한 흐름을 목표로 설계됐다. 핵심은 Optimizer가 nn.Parameter에만 작동하고, LRScheduler가 Optimizer의 param_groups를 동적으로 갱신하는 구조다. 이 글에서는 각 베이스 클래스의 시그니처와 내부 흐름, nn.Module/nn.Parameter와의 상호작용, 그리고 실제 사용 예제를 코드 스니펫과 함께 상세히 풀어본다.
nn.Parameter)에 대해 step/zero_grad/state_dict/param_groups 관리. 파라미터 이외의 Tensor는 거부한다.param_groups에 저장된 학습률을 시점(epoch/step)에 따라 갱신. Optimizer와 독립적으로 저장/로드 가능.Lucid의 Optimizer는 파라미터/버퍼 시스템 위에 얹혀 있으며, nn.Module.parameters()가 반환하는 Parameter만 받아들인다. LRScheduler는 Optimizer를 입력받아 학습률을 조정하되, Optimizer 내부 상태(state_dict)와 별도로 직렬화된다.
class Optimizer(ABC):
def __init__(self, params: Iterable[nn.Parameter], defaults: dict[str, Any]) -> None: ...
@abstractmethod
def step(self, closure: _OptimClosure | None = None) -> Any | None: ...
def zero_grad(self) -> None: ...
def param_groups_setup(self, params: list[nn.Parameter], defaults: dict[str, Any]) -> list[dict[str, Any]]: ...
def add_param_group(self, param_group: dict[str, Any]) -> None: ...
def state_dict(self) -> dict: ...
def load_state_dict(self, state_dict: dict) -> None: ...
params는 반드시 nn.Parameter 반복자여야 하며, 타입 검증 후 리스트로 보관.defaults는 학습률, weight decay 등 하위 optimizer가 공통으로 쓰는 하이퍼파라미터를 담는다.param_groups: param_groups_setup으로 그룹화(기본은 단일 그룹). 그룹마다 {"params": [...], **defaults} 형태.state: defaultdict(dict)로 파라미터별 상태 저장(모멘텀 버퍼 등), 직렬화 시 인덱스로 매핑.step(closure=None): 하위 클래스가 구현. 클로저는 재계산이 필요한 optimizer(SGD with line search 등) 호환용.zero_grad(): 모든 param_group의 Parameter.grad를 0으로 설정. nn.Parameter.zero_grad를 호출해 grad 누적을 초기화.add_param_group: 새로운 파라미터 세트를 추가. 중복 파라미터가 존재하면 예외를 던져 버그를 방지.params 키만 별도로 취급해 리스트를 유지한다.def state_dict(self) -> dict:
param_to_idx = {p: i for i, p in enumerate(self._flat_params())}
packed_state = {
param_to_idx[p]: copy.deepcopy(st)
for p, st in self.state.items() if p in param_to_idx
}
packed_groups = [...]
return {"state": packed_state, "param_groups": packed_groups}
nn.Module/nn.Parametermodel = MyModule()opt = OptimizerSubclass(model.parameters(), lr=...)loss = criterion(model(x), y)opt.zero_grad() → loss.backward() → opt.step()zero_grad()는 nn.Parameter.zero_grad()를 호출해 grad 필드를 초기화한다.params = [
{"params": model.backbone.parameters(), "lr": 1e-3},
{"params": model.head.parameters(), "lr": 1e-2, "weight_decay": 1e-4},
]
opt = MyOptimizer(params, defaults={"lr": 1e-3, "weight_decay": 0.0})
defaults는 공통값, 그룹 딕셔너리는 특정 하이퍼파라미터를 덮어쓴다.
class LRScheduler(ABC):
def __init__(self, optimizer: Optimizer, last_epoch: int = -1, verbose: bool = False) -> None: ...
@abstractmethod
def get_lr(self) -> list[float]: ...
def step(self, epoch: int | None = None) -> None: ...
def state_dict(self) -> dict[str, Any]: ...
def load_state_dict(self, state_dict: dict[str, Any]) -> None: ...
@property
def last_lr(self) -> list[float]: ...
param_groups를 가져올 수 있어야 한다.base_lrs: 초기 각 param_group의 학습률. 스케줄 계산의 기준.last_epoch: 현재까지의 step/epoch 카운터. 초기값 -1은 step() 호출 시 0부터 시작하도록 함._last_lr: 직전 step 이후 설정된 학습률 기록.def step(self, epoch=None):
if epoch is None:
self.last_epoch += 1
else:
self.last_epoch = int(epoch)
self._step_count += 1
new_lrs = self.get_lr()
...
for group, lr in zip(self.optimizer.param_groups, new_lrs):
group["lr"] = float(lr)
self._last_lr = [float(g["lr"]) for g in self.optimizer.param_groups]
if self.verbose:
print(f"Epoch {self.last_epoch}: setting learning rates to {self._last_lr}.")
get_lr만 구현하면 된다. 반환 리스트 길이는 param_groups와 같아야 한다.epoch 인자를 직접 주면 스케줄을 외부 카운터와 동기화할 수 있다.last_epoch, base_lrs, _step_count, _last_lr, _group_count를 저장.param_groups를 직접 수정해 학습률을 바꾼다. Optimizer는 이를 참조해 step 수행 시 사용.opt = MyOptimizer(model.parameters(), defaults={"lr": 1e-3})
sched = MyScheduler(opt, ...)
for epoch in range(num_epochs):
for batch in data:
...
loss.backward()
opt.step()
opt.zero_grad()
sched.step() # 또는 sched.step(epoch)last_epoch와 최근 학습률, 스텝 카운트를 저장. param_group 수가 다르면 로딩 시 에러.opt.load_state_dict(opt_state)
sched = MyScheduler(opt, ...)
sched.load_state_dict(sched_state)model = MyModel()
opt = MyOptimizer(model.parameters(), defaults={"lr": 1e-3})
sched = MyScheduler(opt, last_epoch=-1)
for epoch in range(10):
for x, y in loader:
loss = criterion(model(x), y)
opt.zero_grad()
loss.backward()
opt.step()
sched.step() # epoch 단위 스텝
backbone = {"params": model.backbone.parameters(), "lr": 1e-4}
head = {"params": model.head.parameters(), "lr": 1e-3, "weight_decay": 1e-4}
opt = MyOptimizer([backbone, head], defaults={"lr": 1e-3, "weight_decay": 0.0})
sched = MyScheduler(opt, ...)
opt_state = opt.state_dict()
sched_state = sched.state_dict()
# ... save to disk ...
opt.load_state_dict(opt_state)
sched.load_state_dict(sched_state)
nn.Parameter 타입을 강제 검증. add_param_group에서 중복 검사 후 예외. verbose 플래그로 각 스텝의 lr을 출력하는 옵션 제공.Lucid의 Optimizer/LRScheduler 베이스는 PyTorch 호환성을 목표로, Parameter만 다루는 optimizer와 param_group 학습률을 갱신하는 스케줄러라는 단순한 원리를 따른다. 파라미터/버퍼 시스템, 모듈 트리, state_dict 직렬화 흐름과 결합해, 학습 루프에서 opt.zero_grad → backward → opt.step → sched.step 이라는 익숙한 패턴을 그대로 사용할 수 있다. 구체적 알고리즘(SGD, Adam, CosineLR 등)은 이 베이스 위에 얹기만 하면 되며, 상태 저장/로드, 그룹 관리, lr 갱신은 베이스가 일관되게 처리한다.