Source code for sequifier.optimizers.plan

"""Route catalog parameters into a standard, checkpointable optimizer."""

from __future__ import annotations

from dataclasses import dataclass
from fnmatch import fnmatchcase
from typing import Any, Iterable

import torch
from torch import nn

from sequifier.config.optimizer_config import OptimizerPlan, OptimizerSelector
from sequifier.model.parameter_catalog import ParameterCatalog, ParameterDescriptor
from sequifier.optimizers.optimizers import get_optimizer_class


@dataclass(frozen=True)
class RoutedOptimizerGroup:
    id: str
    optimizer_name: str
    parameters: tuple[nn.Parameter, ...]
    learning_rate: float
    arguments: dict[str, Any]


def _matches(descriptor: ParameterDescriptor, selector: OptimizerSelector) -> bool:
    return (
        (
            selector.semantic_groups is None
            or any(
                fnmatchcase(descriptor.semantic_group, pattern)
                for pattern in selector.semantic_groups
            )
        )
        and (selector.component is None or descriptor.component == selector.component)
        and (
            selector.parameter_kind is None
            or descriptor.parameter_kind == selector.parameter_kind
        )
        and (selector.ndim is None or len(descriptor.shape) == selector.ndim)
    )


[docs]def route_optimizer_parameters( plan: OptimizerPlan, catalog: ParameterCatalog, parameters: Iterable[nn.Parameter], ) -> tuple[RoutedOptimizerGroup, ...]: """Assign every supplied trainable parameter once, rejecting ambiguous routes.""" supplied = { id(parameter): parameter for parameter in parameters if parameter.requires_grad } grouped: dict[str, list[nn.Parameter]] = {group.id: [] for group in plan.groups} seen: set[int] = set() explicit = plan.groups[:-1] fallback = plan.groups[-1] for descriptor in catalog.descriptors(): parameter = catalog.parameter(descriptor.parameter_id) identity = id(parameter) if identity not in supplied or identity in seen: continue seen.add(identity) matches = [ group.id for group in explicit if isinstance(group.select, OptimizerSelector) and _matches(descriptor, group.select) ] if len(matches) > 1: raise ValueError( f"Parameter {descriptor.canonical_name!r} matches multiple optimizer " f"groups: {matches!r}." ) grouped[matches[0] if matches else fallback.id].append(parameter) if seen != set(supplied): raise ValueError( "The parameter catalog did not cover all trainable optimizer parameters." ) empty = [group.id for group in plan.groups if not grouped[group.id]] if empty: raise ValueError(f"Optimizer groups have no trainable parameters: {empty!r}.") return tuple( RoutedOptimizerGroup( id=group.id, optimizer_name=group.optimizer.name, parameters=tuple(grouped[group.id]), learning_rate=group.learning_rate, arguments=dict(group.optimizer.arguments), ) for group in plan.groups )
[docs]class CompositeOptimizer(torch.optim.Optimizer): """Expose several disjoint optimizers as one ordinary PyTorch optimizer. Child groups and state are shared with this facade. This lets schedulers, gradient scaling, integrations, and PyTorch state-dict helpers operate on one optimizer with the usual ``state`` and ``param_groups`` layout. """ def __init__(self, groups: tuple[RoutedOptimizerGroup, ...]): if not groups: raise ValueError("A composite optimizer requires at least one group.") super().__init__([{"params": group.parameters} for group in groups], {}) children: list[torch.optim.Optimizer] = [] exposed_groups: list[dict[str, Any]] = [] for group in groups: optimizer_class = get_optimizer_class(group.optimizer_name) if optimizer_class is torch.optim.LBFGS: raise ValueError( "LBFGS requires closure-based steps and cannot be routed." ) child = optimizer_class( [{"params": group.parameters, "group_id": group.id}], lr=group.learning_rate, **group.arguments, ) self.state.update(child.state) child.state = self.state children.append(child) exposed_groups.append(child.param_groups[0]) self._children = children self.param_groups = exposed_groups
[docs] @torch.no_grad() def step(self, closure=None): loss = None if closure is not None: with torch.enable_grad(): loss = closure() for child in self._children: child.step() return loss
[docs] def load_state_dict(self, state_dict: dict[str, Any]) -> None: saved_ids = [ group.get("group_id") for group in state_dict.get("param_groups", ()) ] current_ids = [group["group_id"] for group in self.param_groups] if saved_ids != current_ids: raise ValueError( "Checkpoint optimizer groups do not match the current plan: " f"{saved_ids!r} != {current_ids!r}." ) super().load_state_dict(state_dict) for child, group in zip(self._children, self.param_groups): child.param_groups = [group] child.state = self.state