Source code for tidy3d.plugins.autograd.optimizers

"""Standalone optimizers for use with tidy3d autograd.

Mirrors the `optax <https://optax.readthedocs.io>`_ API:

* ``optimizer.init(params)`` → state
* ``optimizer.update(grads, state, params)`` → (updates, new_state)
* ``apply_updates(params, updates)`` → new_params

Supports parameters as either a single ``np.ndarray`` or a ``dict`` mapping
string keys to ``np.ndarray`` / scalar values (pytree-style).
"""

from __future__ import annotations

from collections.abc import Callable
from typing import TYPE_CHECKING, Literal, get_args

import numpy as np
from pydantic import Field, PositiveFloat

from tidy3d.components.base import Tidy3dBaseModel

from .differential_operators import value_and_grad

if TYPE_CHECKING:
    from typing import TypeAlias

ParamLeaf: TypeAlias = np.ndarray | float
Params = ParamLeaf | dict[str, ParamLeaf]
Bounds = tuple[float | None, float | None] | dict[str, tuple[float | None, float | None]]
ObjectiveValue: TypeAlias = float | np.ndarray
ObjectiveFn: TypeAlias = Callable[[Params], ObjectiveValue]
OptimizeDirection: TypeAlias = Literal["min", "max"]
OPTIMIZE_DIRECTIONS = list(get_args(OptimizeDirection))
AdamState: TypeAlias = dict[str, Params | int]
OptimizeHistory: TypeAlias = dict[str, list[float]]
OptimizeCallback: TypeAlias = Callable[[Params, Params, AdamState, int, ObjectiveValue], None]


def _tree_map(fn: Callable[..., ParamLeaf], *trees: Params) -> Params:
    """Apply ``fn`` element-wise across one or more matching pytrees (dicts or arrays)."""
    first = trees[0]
    if isinstance(first, dict):
        return {k: _tree_map(fn, *(t[k] for t in trees)) for k in first}
    return fn(*trees)


def _tree_reduce(fn: Callable[[ParamLeaf], float], tree: Params, initializer: float = 0.0) -> float:
    """Reduce all leaves of a pytree to a single scalar."""
    if isinstance(tree, dict):
        return sum((_tree_reduce(fn, v, initializer) for v in tree.values()), initializer)
    return fn(tree)


[docs] class Adam(Tidy3dBaseModel): """Adam optimizer (optax-compatible interface). Supports parameters as a single ``np.ndarray`` or a ``dict`` mapping string keys to arrays/scalars. Parameters ---------- learning_rate : float Step size for the parameter updates. beta1 : float = 0.9 Exponential decay rate for the first moment estimate. beta2 : float = 0.999 Exponential decay rate for the second moment estimate. eps : float = 1e-8 Small constant for numerical stability. Example ------- >>> opt = adam(learning_rate=0.01) # doctest: +SKIP >>> state = opt.init(params) # doctest: +SKIP >>> for step in range(100): # doctest: +SKIP ... val, grad = value_and_grad(obj_fn)(params) ... updates, state = opt.update(grad, state, params) ... params = apply_updates(params, updates) """ learning_rate: PositiveFloat = Field( title="Learning Rate", description="Step size for the parameter updates.", ) beta1: float = Field( 0.9, ge=0.0, le=1.0, title="Beta 1", description="Exponential decay rate for the first moment estimate.", ) beta2: float = Field( 0.999, ge=0.0, le=1.0, title="Beta 2", description="Exponential decay rate for the second moment estimate.", ) eps: PositiveFloat = Field( 1e-8, title="Epsilon", description="Small constant for numerical stability.", )
[docs] def init(self, params: Params) -> AdamState: """Create the initial optimizer state. Parameters ---------- params : np.ndarray or dict Initial parameters (array or dict of arrays), used to determine the shape of moment estimates. Returns ------- dict Initial optimizer state with keys ``"m"``, ``"v"``, and ``"t"``. """ return { "m": _tree_map(np.zeros_like, params), "v": _tree_map(np.zeros_like, params), "t": 0, }
[docs] def update( self, grads: Params, state: AdamState, params: Params | None = None ) -> tuple[Params, AdamState]: """Compute parameter updates from gradients (optax-compatible). Parameters ---------- grads : np.ndarray or dict Gradient of the objective with respect to parameters. state : dict Current optimizer state. params : np.ndarray or dict, optional Current parameters (unused by Adam, accepted for API compatibility). Returns ------- tuple ``(updates, new_state)`` where ``updates`` is the additive delta to apply via :func:`apply_updates`. """ m = state["m"] v = state["v"] t = int(state["t"]) + 1 b1, b2, lr, eps = self.beta1, self.beta2, self.learning_rate, self.eps b1_corr = 1 - b1**t b2_corr = 1 - b2**t m = _tree_map(lambda m_i, g_i: b1 * np.asarray(m_i) + (1 - b1) * g_i, m, grads) v = _tree_map(lambda v_i, g_i: b2 * np.asarray(v_i) + (1 - b2) * g_i**2, v, grads) updates = _tree_map( lambda m_i, v_i: -lr * (m_i / b1_corr) / (np.sqrt(v_i / b2_corr) + eps), m, v, ) new_state = {"m": m, "v": v, "t": t} return updates, new_state
[docs] def adam(learning_rate: float, beta1: float = 0.9, beta2: float = 0.999, eps: float = 1e-8) -> Adam: """Create an Adam optimizer (convenience factory, mirrors ``optax.adam``). Parameters ---------- learning_rate : float Step size for the parameter updates. beta1 : float = 0.9 Exponential decay rate for the first moment estimate. beta2 : float = 0.999 Exponential decay rate for the second moment estimate. eps : float = 1e-8 Small constant for numerical stability. Returns ------- Adam Configured Adam optimizer instance. """ return Adam(learning_rate=learning_rate, beta1=beta1, beta2=beta2, eps=eps)
[docs] def apply_updates(params: Params, updates: Params) -> Params: """Apply additive updates to parameters (mirrors ``optax.apply_updates``). Parameters ---------- params : np.ndarray or dict Current parameters. updates : np.ndarray or dict Additive updates (as returned by ``optimizer.update()``). Returns ------- np.ndarray or dict Updated parameters (``params + updates``). """ return _tree_map(lambda p, u: p + u, params, updates)
def _grad_norm(grad: Params) -> float: """Compute the L2 norm of a gradient (array or dict of arrays).""" ss = _tree_reduce(lambda x: float(np.sum(np.asarray(x) ** 2)), grad) return float(np.sqrt(ss)) def _clip_params(params: Params, bounds: Bounds) -> Params: """Clip parameters according to bounds. Parameters ---------- params : np.ndarray or dict Current parameters. bounds : tuple or dict For array params: ``(lo, hi)`` tuple where ``None`` means unbounded. For dict params: ``dict`` mapping keys to ``(lo, hi)`` tuples. Keys missing from ``bounds`` are left unclipped. Returns ------- np.ndarray or dict Clipped parameters. """ if isinstance(params, dict): if isinstance(bounds, dict): return {k: np.clip(v, *bounds[k]) if k in bounds else v for k, v in params.items()} lo, hi = bounds return {k: np.clip(v, lo, hi) for k, v in params.items()} lo, hi = bounds return np.clip(params, lo, hi)
[docs] def optimize( objective_fn: ObjectiveFn, params0: Params, optimizer: Adam, num_steps: int, *, bounds: Bounds | None = None, callback: OptimizeCallback | None = None, direction: OptimizeDirection = "min", ) -> tuple[Params, AdamState, OptimizeHistory]: """Run a full gradient-descent optimization loop (convenience wrapper). This is a thin convenience wrapper around the optax-style stepping API provided by this module (no optax dependency required). It is not intended to grow into a full optimization framework — for advanced use cases (custom stopping criteria, checkpointing, schedulers, DRC-aware updates, etc.) use the lower-level ``optimizer.init`` / ``optimizer.update`` / ``apply_updates`` interface directly. Uses ``autograd.value_and_grad`` to compute gradients of ``objective_fn`` at each step, then updates parameters using the provided ``optimizer``. Parameters ---------- objective_fn : Callable[[Params], Union[float, np.ndarray]] Scalar-valued objective function evaluated on the current parameters. It must accept the same parameter structure as ``params0`` and return a differentiable scalar (Python ``float`` or scalar ``np.ndarray``) that ``autograd.value_and_grad`` can handle. params0 : np.ndarray, float, or dict Initial parameter values as a single array, a scalar, or a dict of arrays/scalars. optimizer : Adam Optimizer instance that provides ``.init()`` and ``.update()`` methods. num_steps : int Number of optimization steps to run. bounds : tuple or dict, optional Parameter bounds applied after each update step. For array params: a ``(lo, hi)`` tuple where ``None`` disables a side. For dict params: a ``dict`` mapping parameter keys to ``(lo, hi)`` tuples. Keys absent from the dict are left unclipped. callback : Optional[Callable[[Params, Params, AdamState, int, Union[float, np.ndarray]], None]] If provided, called each step **before** the parameter update as ``callback(params, grad, state, step_index, objective_val)``. All arguments reflect the pre-update state of the current iterate, and ``grad`` / ``objective_val`` are the raw outputs of ``objective_fn`` before any min/max direction handling. The final (post-loop) params are available in the return value. direction : {"min", "max"} = "min" Optimization direction. ``"min"`` performs gradient descent on ``objective_fn``. ``"max"`` performs gradient ascent by negating the gradient passed to the optimizer while still recording the raw objective values in ``history["objective_fn_val"]``. Returns ------- tuple ``(params, state, history)`` where ``history`` is a dict with keys ``"objective_fn_val"`` and ``"grad_norm"``, each a list of per-step values of the raw objective and raw gradient norm, respectively. """ for attr in ("init", "update"): if not callable(getattr(optimizer, attr, None)): raise TypeError( f"'optimizer' must have a callable '.{attr}()' method. " f"Got {type(optimizer).__name__}. " f"Use 'from tidy3d.plugins.autograd import adam; adam(learning_rate=...)' to create one." ) if direction not in OPTIMIZE_DIRECTIONS: raise ValueError(f"'direction' must be one of {OPTIMIZE_DIRECTIONS}. Got {direction!r}.") val_and_grad_fn = value_and_grad(objective_fn) state = optimizer.init(params0) params = params0 history = {"objective_fn_val": [], "grad_norm": []} for step_index in range(num_steps): val, grad = val_and_grad_fn(params) history["objective_fn_val"].append(float(val)) history["grad_norm"].append(_grad_norm(grad)) if callback is not None: callback(params, grad, state, step_index, val) step_grad = grad if direction == "min" else _tree_map(np.negative, grad) updates, state = optimizer.update(step_grad, state, params) params = apply_updates(params, updates) if bounds is not None: params = _clip_params(params, bounds) return params, state, history