| """Optimizer parameter groups that honour what the models ask for. |
| |
| State-space parameters are not ordinary weights. ``A`` controls how fast a |
| state decays and ``dt`` its timescale; both sit inside an exponential, so a |
| weight-decay term pulling them toward zero is not a mild regulariser but a |
| change to the dynamics, and a learning rate suited to a projection matrix will |
| walk them straight out of the stable region. Every upstream implementation |
| knows this and says so **in the parameters themselves**: |
| |
| - ``state-spaces/s4`` attaches ``param._optim = {"lr": ..., "weight_decay": 0.0}`` |
| to the SSM kernel parameters (``kernel.py``'s ``register``); |
| - ``state-spaces/mamba`` attaches ``param._no_weight_decay = True`` to |
| ``A_log``, ``D`` and ``dt_bias``. |
| |
| Those tags travel with the vendored code but do nothing on their own — an |
| optimizer has to read them. Anyone who writes the obvious thing:: |
| |
| torch.optim.AdamW(model.parameters(), lr=1e-3) |
| |
| is training an S4 or a Mamba in the way its authors explicitly avoid, and |
| nothing complains. This module reads the tags:: |
| |
| opt = torch.optim.AdamW(td.param_groups(model, lr=1e-3), lr=1e-3) |
| |
| The published recipes agree on the shape of the rest: AdamW with |
| ``betas=(0.9, 0.95)``, weight decay ~0.1 on ordinary weights, gradient |
| clipping at 1.0, and a linear warmup into a cosine decay. Norms and biases are |
| conventionally excluded from weight decay too, which :func:`param_groups` |
| does — a one-dimensional parameter has no direction for decay to shrink |
| meaningfully. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from typing import Any |
|
|
| import torch |
| import torch.nn as nn |
|
|
| __all__ = ["param_groups", "warmup_cosine"] |
|
|
| |
| |
| SSM_MAX_LR = 1e-3 |
|
|
|
|
| def param_groups( |
| model: nn.Module, |
| *, |
| lr: float, |
| weight_decay: float = 0.1, |
| ssm_lr: float | None = None, |
| decay_1d: bool = False, |
| ) -> list[dict[str, Any]]: |
| """Split a model's parameters into groups an optimizer can take directly. |
| |
| Args: |
| model: any module; tags are read per parameter, so this works on the |
| vendored upstream blocks and on our portable ones alike. |
| lr: the learning rate for ordinary weights. |
| weight_decay: decay for ordinary weights. Never applied to a parameter |
| that asks not to have it. |
| ssm_lr: learning rate for tagged state-space parameters. Defaults to |
| ``min(lr, 1e-3)`` — upstream's ceiling, and it lowers a too-high |
| ``lr`` rather than raising a deliberately low one. |
| decay_1d: apply weight decay to one-dimensional parameters (norms, |
| biases). Off by default, as is conventional. |
| |
| Returns: |
| A list of parameter-group dicts. Groups with no members are dropped, so |
| the result can be handed to any optimizer without special-casing. |
| |
| The tags come from the upstream authors and are respected exactly: a |
| parameter carrying ``_optim`` gets those settings, one carrying |
| ``_no_weight_decay`` gets ``weight_decay=0``, and anything else is an |
| ordinary weight. |
| """ |
| if ssm_lr is None: |
| ssm_lr = min(lr, SSM_MAX_LR) |
|
|
| ordinary: list[nn.Parameter] = [] |
| no_decay: list[nn.Parameter] = [] |
| ssm: list[nn.Parameter] = [] |
| |
| |
| special: dict[tuple, list[nn.Parameter]] = {} |
|
|
| for param in model.parameters(): |
| if not param.requires_grad: |
| continue |
| explicit = getattr(param, "_optim", None) |
| if explicit: |
| key = tuple(sorted(explicit.items())) |
| special.setdefault(key, []).append(param) |
| elif getattr(param, "_no_weight_decay", False): |
| ssm.append(param) |
| elif param.ndim <= 1 and not decay_1d: |
| no_decay.append(param) |
| else: |
| ordinary.append(param) |
|
|
| groups: list[dict[str, Any]] = [] |
| if ordinary: |
| groups.append({"params": ordinary, "lr": lr, "weight_decay": weight_decay}) |
| if no_decay: |
| groups.append({"params": no_decay, "lr": lr, "weight_decay": 0.0}) |
| if ssm: |
| |
| groups.append({"params": ssm, "lr": ssm_lr, "weight_decay": 0.0}) |
| for key, params in special.items(): |
| settings = dict(key) |
| groups.append( |
| { |
| "params": params, |
| "lr": settings.get("lr", ssm_lr), |
| "weight_decay": settings.get("weight_decay", 0.0), |
| } |
| ) |
| return groups |
|
|
|
|
| def warmup_cosine( |
| optimizer: torch.optim.Optimizer, *, warmup: int, total: int, floor: float = 0.0 |
| ) -> torch.optim.lr_scheduler.LambdaLR: |
| """Linear warmup into cosine decay — the schedule the papers use. |
| |
| Args: |
| warmup: steps spent ramping linearly from zero to the group's own |
| learning rate. |
| total: total training steps; the cosine completes over what is left. |
| floor: fraction of the peak rate to end at, rather than zero. |
| |
| Scaling is multiplicative, so each parameter group keeps its own rate — |
| the SSM group stays below the others throughout instead of being flattened |
| to one schedule, which is the point of having separated them. |
| """ |
| if warmup < 0 or total <= 0: |
| raise ValueError(f"need total > 0 and warmup >= 0; got {total=}, {warmup=}") |
|
|
| def scale(step: int) -> float: |
| if step < warmup: |
| |
| return (step + 1) / max(warmup, 1) |
| if total <= warmup: |
| return 1.0 |
| progress = (step - warmup) / (total - warmup) |
| import math |
|
|
| cosine = 0.5 * (1.0 + math.cos(math.pi * min(progress, 1.0))) |
| return floor + (1.0 - floor) * cosine |
|
|
| return torch.optim.lr_scheduler.LambdaLR(optimizer, scale) |
|
|