import torch from torch import Tensor from torch.optim import Optimizer from torch.optim.optimizer import ParamsT from dataclasses import dataclass from typing import Any, Dict, List, Type, Callable, Optional @dataclass class OptimizerSpec: """Spec for creating an optimizer that is part of a `ChainedOptimizer`.""" class_type: Type[Optimizer] init_args: Dict[str, Any] param_filter: Optional[Callable[[Tensor], bool]] class ChainedOptimizer(Optimizer): """ A wrapper around multiple optimizers that allows for chaining them together. The optimizers are applied in the order they are passed in the constructor. Each optimizer is responsible for updating a subset of the parameters, which is determined by the `param_filter` function. If no optimizer is found for a parameter group, an exception is raised. """ def __init__( self, params: ParamsT, optimizer_specs: List[OptimizerSpec], lr: float, weight_decay: float = 0.0, optimizer_selection_callback: Optional[Callable[[Tensor, int], None]] = None, **common_kwargs, ): self.optimizer_specs = optimizer_specs self.optimizer_selection_callback = optimizer_selection_callback self.optimizers: List[Optimizer] = [] defaults = dict(lr=lr, weight_decay=weight_decay) super().__init__(params, defaults) # Split the params for each optimizer params_for_optimizers = [[] for _ in optimizer_specs] for param_group in self.param_groups: params = param_group["params"] indices = param_group["optimizer_and_param_group_indices"] = set() for param in params: assert isinstance(param, Tensor), f"Expected a Tensor, got {type(param)}" found_optimizer = False for index, spec in enumerate(optimizer_specs): if spec.param_filter is None or spec.param_filter(param): if self.optimizer_selection_callback is not None: self.optimizer_selection_callback(param, index) params_for_optimizers[index].append(param) indices.add((index, 0)) found_optimizer = True break if not found_optimizer: raise ValueError("No valid optimizer found for the given parameter") # Initialize the optimizers for spec, selected_params in zip(optimizer_specs, params_for_optimizers): optimizer_args = { 'lr': lr, 'weight_decay': weight_decay, } optimizer_args.update(common_kwargs) optimizer_args.update(spec.init_args) optimizer = spec.class_type(selected_params, **optimizer_args) self.optimizers.append(optimizer) def state_dict(self) -> Dict[str, Any]: return { "optimizers": [opt.state_dict() for opt in self.optimizers], **super().state_dict(), } def load_state_dict(self, state_dict: Dict[str, Any]) -> None: optimizers = state_dict.pop("optimizers") super().load_state_dict(state_dict) for i in range(len(self.optimizers)): self.optimizers[i].load_state_dict(optimizers[i]) def zero_grad(self, set_to_none: bool = True) -> None: for opt in self.optimizers: opt.zero_grad(set_to_none=set_to_none) def _copy_lr_to_optimizers(self) -> None: for param_group in self.param_groups: indices = param_group["optimizer_and_param_group_indices"] for optimizer_idx, param_group_idx in indices: self.optimizers[optimizer_idx].param_groups[param_group_idx]["lr"] = param_group["lr"] def step(self, closure=None) -> None: loss = None if closure is not None: with torch.enable_grad(): loss = closure() self._copy_lr_to_optimizers() for opt in self.optimizers: opt.step(closure=None) return loss def add_param_group(self, param_group: Dict[str, Any]) -> None: super().add_param_group(param_group) # If optimizer has not been initialized, skip adding the param groups if not self.optimizers: return # Split the params for each optimizer params_for_optimizers = [[] for _ in self.optimizer_specs] params = param_group["params"] indices = param_group["optimizer_and_param_group_indices"] = set() for param in params: assert isinstance(param, Tensor), f"Expected a Tensor, got {type(param)}" found_optimizer = False for index, spec in enumerate(self.optimizer_specs): if spec.param_filter is None or spec.param_filter(param): if self.optimizer_selection_callback is not None: self.optimizer_selection_callback(param, index) params_for_optimizers[index].append(param) indices.add((index, len(self.optimizers[index].param_groups))) found_optimizer = True break if not found_optimizer: raise ValueError("No valid optimizer found for the given parameter group") # Add the selected param group to the optimizers for optimizer, selected_params in zip(self.optimizers, params_for_optimizers): if selected_params: optimizer.add_param_group({"params": selected_params})