# Copyright 2026 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Vectorised multi-start minimisation of a pure, differentiable function.
:func:`multistart_minimize` runs one local optimiser (L-BFGS, Adam or gradient
descent from ``optax``) from ``S`` starting points at once: the per-start
``lax.while_loop`` is ``vmap``-ped and compiled into a single XLA program, and
every start stops on its own convergence test.
"""
from typing import Any, Callable, NamedTuple, Optional, Union
import brainstate
import brainunit as u
import jax
import jax.numpy as jnp
import numpy as np
import optax
from braintools._misc import set_module_as
__all__ = [
'MultiStartResult',
'multistart_minimize',
'multistart_init',
]
_METHODS = ('lbfgs', 'adam', 'gd')
# Starting points are pulled this fraction of the interval width inside the
# bounds before the bijector is inverted. A start on a bound would otherwise map
# to an infinite unconstrained value; 1e-3 also keeps the bijector's slope (about
# 1e-3 of the width) large enough for the start to move.
_EDGE = 1e-3
[docs]
class MultiStartResult(NamedTuple):
"""Result of :func:`multistart_minimize`.
Attributes
----------
x : PyTree
Final point of every start; leaves have a leading axis of size ``S``.
fun : jax.Array
Final loss of every start, shape ``(S,)``.
converged : jax.Array
Boolean ``(S,)``: the start met the convergence test (and its loss is
finite) before ``max_iter`` iterations.
n_iter : jax.Array
Integer ``(S,)``: iterations run by every start.
best_x : PyTree
The point of the start with the lowest finite loss (no leading axis).
best_fun : jax.Array
Loss of ``best_x`` (a scalar).
best_index : jax.Array
Index of the best start (an integer scalar).
history : jax.Array
Loss after every iteration, shape ``(S, max_iter)``. Entries after a
start stopped repeat its final loss.
"""
x: Any
fun: jax.Array
converged: jax.Array
n_iter: jax.Array
best_x: Any
best_fun: jax.Array
best_index: jax.Array
history: jax.Array
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _is_pair(x: Any) -> bool:
"""A ``(low, high)`` bound pair (a 2-tuple of non-container values)."""
return (
isinstance(x, tuple) and len(x) == 2
and not any(isinstance(e, (tuple, list, dict)) for e in x)
)
def _is_concrete(x: Any) -> bool:
return not isinstance(u.get_mantissa(x), jax.core.Tracer)
def _bound_mantissa(value: Any, unit: Optional[u.Unit], name: str) -> Any:
"""Express a bound in ``unit`` (the unit of its leaf) as a plain array."""
if value is None:
return None
if unit is None:
if isinstance(value, u.Quantity) and not value.dim.is_dimensionless:
raise TypeError(
f'{name} carries the unit {value.unit}, but the parameter it bounds has no unit.'
)
return jnp.asarray(u.get_mantissa(value))
try:
return jnp.asarray(u.Quantity(value).to_decimal(unit))
except u.UnitMismatchError as e:
raise TypeError(f'{name} must be expressed in a unit compatible with {unit}: {e}') from None
def _split_leaves(tree: Any):
"""Flatten ``tree`` into plain mantissa leaves, their units, and the treedef."""
leaves, treedef = jax.tree.flatten(tree, is_leaf=u.math.is_quantity)
mantissas, units = [], []
for leaf in leaves:
if isinstance(leaf, u.Quantity):
mantissas.append(jnp.asarray(leaf.mantissa))
units.append(leaf.unit)
else:
mantissas.append(jnp.asarray(leaf))
units.append(None)
return mantissas, units, treedef
def _join_leaves(mantissas, units, treedef):
leaves = [m if unit is None else u.Quantity(m, unit) for m, unit in zip(mantissas, units)]
return jax.tree.unflatten(treedef, leaves)
def _make_bijector(bound: Any, unit: Optional[u.Unit], index: int):
"""Return the brainstate transform for one bound entry, or ``None``."""
if bound is None:
return None
if not _is_pair(bound):
raise ValueError(
f'Each bounds entry must be None or a (low, high) pair; got {bound!r} for leaf {index}.'
)
low = _bound_mantissa(bound[0], unit, f'Lower bound of leaf {index}')
high = _bound_mantissa(bound[1], unit, f'Upper bound of leaf {index}')
for side, v in (('lower', low), ('upper', high)):
if v is not None and _is_concrete(v) and not np.all(np.isfinite(np.asarray(v))):
raise ValueError(
f'The {side} bound of leaf {index} must be finite; use None for an open side.'
)
if low is None and high is None:
return None
if high is None:
return brainstate.nn.SoftplusT(low)
if low is None:
return brainstate.nn.NegSoftplusT(high)
if _is_concrete(low) and _is_concrete(high) and np.any(np.asarray(low) >= np.asarray(high)):
raise ValueError(f'Bounds of leaf {index} must satisfy low < high; got {bound!r}.')
return brainstate.nn.SigmoidT(low, high)
def _pull_inside(x: jax.Array, t: Any) -> jax.Array:
"""Move a constrained value strictly inside the domain of transform ``t``."""
if isinstance(t, brainstate.nn.SigmoidT):
pad = _EDGE * t.width
return jnp.clip(x, t.lower + pad, t.lower + t.width - pad)
if isinstance(t, brainstate.nn.NegSoftplusT):
return jnp.minimum(x, t.lower - _EDGE * jnp.maximum(1.0, jnp.abs(t.lower)))
# SoftplusT
return jnp.maximum(x, t.lower + _EDGE * jnp.maximum(1.0, jnp.abs(t.lower)))
def _resolve_bijectors(bounds: Any, units, treedef):
if bounds is None:
return [None] * len(units)
if treedef.num_leaves == 1 and _is_pair(bounds):
entries = [bounds]
else:
try:
entries = treedef.flatten_up_to(bounds)
except (ValueError, TypeError) as e:
raise ValueError(
'`bounds` must mirror the structure of `x0`, with a (low, high) pair '
f'or None in place of every leaf: {e}'
) from None
return [_make_bijector(b, unit, i) for i, (b, unit) in enumerate(zip(entries, units))]
def _make_optimizer(method: Any, learning_rate: Any):
if isinstance(method, str):
name = method.lower()
if name == 'lbfgs':
return optax.lbfgs(), True
if name == 'adam':
return optax.adam(learning_rate), False
if name == 'gd':
return optax.sgd(learning_rate), False
raise ValueError(f'Unknown method {method!r}; expected one of {_METHODS} '
f'or an optax.GradientTransformation.')
if isinstance(method, optax.GradientTransformation):
return optax.with_extra_args_support(method), False
raise TypeError(
f'`method` must be one of {_METHODS} or an optax.GradientTransformation, '
f'got {type(method).__name__}.'
)
def _scalar_loss(value: Any) -> jax.Array:
if isinstance(value, u.Quantity):
if not value.dim.is_dimensionless:
raise TypeError(f'`fun` must return a dimensionless scalar, got unit {value.unit}.')
value = value.mantissa
value = jnp.asarray(value)
if value.ndim != 0:
raise ValueError(f'`fun` must return a scalar, got shape {value.shape}.')
return value
# ---------------------------------------------------------------------------
# public API
# ---------------------------------------------------------------------------
@set_module_as('braintools.optim')
def multistart_init(key: Any, bounds: Any, n_starts: int) -> Any:
"""Draw uniform starting points within box bounds.
Parameters
----------
key : jax.Array
A ``jax.random`` key.
bounds : PyTree
A pytree whose leaves are ``(low, high)`` pairs (scalars, arrays or
``brainunit`` Quantities of a common unit). A bare pair describes a
single leaf.
n_starts : int
Number of starting points ``S``.
Returns
-------
PyTree
The structure of ``bounds`` with every pair replaced by an array of
shape ``(S, *broadcast(low, high).shape)`` drawn uniformly from
``[low, high)``; a Quantity in the unit of ``low`` when the bounds
carry one.
Raises
------
ValueError
If ``n_starts`` is not a positive integer.
See Also
--------
multistart_minimize : Minimise from the drawn starts.
Notes
-----
``n_starts`` fixes the output shape, so it must be a Python ``int``. The
function works under ``jax.jit`` (with ``n_starts`` static) and
``jax.vmap`` over ``key``.
Examples
--------
.. code-block:: python
>>> import jax
>>> from braintools.optim import multistart_init
>>> x0 = multistart_init(jax.random.PRNGKey(0), {'a': (0., 1.), 'b': (-2., 2.)}, 4)
>>> x0['a'].shape, x0['b'].shape
((4,), (4,))
>>> bool(((x0['b'] >= -2.) & (x0['b'] < 2.)).all())
True
"""
if isinstance(n_starts, bool) or not isinstance(n_starts, (int, np.integer)) or n_starts < 1:
raise ValueError(f'`n_starts` must be a positive integer, got {n_starts!r}.')
pairs, treedef = jax.tree.flatten(bounds, is_leaf=_is_pair)
if not pairs:
raise ValueError('`bounds` contains no (low, high) pair.')
keys = jax.random.split(key, len(pairs))
out = []
for k, pair in zip(keys, pairs):
if not _is_pair(pair):
raise ValueError(f'Every leaf of `bounds` must be a (low, high) pair, got {pair!r}.')
low = u.Quantity(pair[0])
unit = low.unit
lo = jnp.asarray(low.mantissa, dtype=brainstate.environ.dftype())
hi = jnp.asarray(u.Quantity(pair[1]).to_decimal(unit), dtype=lo.dtype)
shape = (int(n_starts),) + jnp.broadcast_shapes(lo.shape, hi.shape)
draw = jax.random.uniform(k, shape, dtype=lo.dtype, minval=lo, maxval=hi)
out.append(draw if unit.is_unitless else u.Quantity(draw, unit))
return jax.tree.unflatten(treedef, out)
@set_module_as('braintools.optim')
def multistart_minimize(
fun: Callable[[Any], Any],
x0: Any,
*,
method: Union[str, optax.GradientTransformation] = 'lbfgs',
max_iter: int = 500,
tol: float = 1e-6,
bounds: Any = None,
learning_rate: Any = 1e-2,
) -> MultiStartResult:
r"""Minimise a scalar function from many starting points in parallel.
Every start runs the same local optimiser inside a ``lax.while_loop``;
the loop is ``vmap``-ped over the starts and the whole run is compiled
into one XLA program. A start stops when it converges, when its loss
becomes non-finite, or after ``max_iter`` iterations, while the other
starts keep going.
Parameters
----------
fun : callable
``fun(x) -> scalar``. A pure, differentiable function of one start
``x`` (a pytree shaped like a single slice of ``x0``). Its value must
be a plain or dimensionless scalar.
x0 : PyTree
Starting points: every leaf has a leading axis of size ``S``, the
number of starts. Leaves may be ``brainunit`` Quantities; ``fun`` then
receives Quantities in the same units.
method : {'lbfgs', 'adam', 'gd'} or optax.GradientTransformation, default 'lbfgs'
Local optimiser. ``'lbfgs'`` is :func:`optax.lbfgs` with its default
zoom line search (``learning_rate`` is ignored); ``'adam'`` and
``'gd'`` are :func:`optax.adam` and :func:`optax.sgd` with step size
``learning_rate``. Any other ``optax`` transformation can be passed
directly.
max_iter : int, default 500
Maximum number of iterations per start (static).
tol : float, default 1e-6
Convergence tolerance. A start has converged when the largest
absolute gradient entry (in the unconstrained space) is below
``tol``, or when the loss changes by a relative amount
:math:`|f_k - f_{k-1}| \le \mathrm{tol}\,|f_{k-1}|` in one
iteration (so a step that leaves the loss unchanged also counts).
bounds : PyTree, optional
Box bounds mirroring the structure of ``x0``: a ``(low, high)`` pair
or ``None`` in place of every leaf (a bare pair for a single-leaf
``x0``). Bounds broadcast against one start of the leaf and may be
Quantities in the leaf's unit. ``low`` or ``high`` may be ``None`` for
a one-sided bound. A bounded leaf is optimised in an unconstrained
space through :class:`brainstate.nn.SigmoidT` (``SoftplusT`` /
``NegSoftplusT`` for one-sided bounds) and mapped back, so iterates
never leave the box.
learning_rate : float or optax schedule, default 1e-2
Step size for ``'adam'`` and ``'gd'``.
Returns
-------
MultiStartResult
``(x, fun, converged, n_iter, best_x, best_fun, best_index,
history)``; see :class:`MultiStartResult`.
Raises
------
ValueError
If ``method`` is unknown, ``max_iter`` is not a positive integer,
``x0`` has no leaves or leaves with different leading sizes, or a
bound is malformed (wrong structure, non-finite, ``low >= high``).
TypeError
If ``fun`` returns a value with a physical unit, or a bound's unit
does not match its leaf.
See Also
--------
multistart_init : Uniform starting points within bounds.
braintools.fit.Fitter : Model fitting built on this minimiser.
Notes
-----
**Convergence test.** The gradient test uses the gradient with respect
to the *constrained* value (the chain-rule gradient divided by the slope
of the bijector), so a start near a bound, where the sigmoid is flat,
is not mistaken for a stationary point. At an optimum on a bound that
gradient stays non-zero, and only the loss-change test can stop the
start. The loss-change test is used for ``'lbfgs'`` only: its line
search makes a negligible decrease a sign of convergence, whereas the
per-step change of a fixed-step method (``'adam'``, ``'gd'``, a custom
``optax`` transformation) reflects the step size rather than the
distance to the optimum (with a large constant offset in the loss, a
relative test would stop such a method far from the minimum). These
methods therefore stop on the gradient test or after ``max_iter``.
Conversely, for ``'lbfgs'`` on a loss with a large constant offset the
relative test can stop early; lower ``tol`` (or remove the offset) then.
**Starts on a bound.** Starting values are moved ``1e-3`` of the
interval width inside the bounds (``1e-3 * max(1, |bound|)`` for a
one-sided bound) before the bijector is inverted, so starts on (or
outside) a bound are finite and still move.
**Non-finite losses.** A start whose loss at ``x0`` is not finite stops
immediately; a start whose loss becomes non-finite during the run
(it *diverged*) stops and keeps its last finite point. Either way it is
reported with ``converged=False`` and ``n_iter < max_iter``, and it is
never selected as the best while another start has a finite loss and
did not diverge. If every start diverged, the lowest finite loss among
them is selected; if no loss is finite, ``best_index`` is 0 and
``best_fun`` is not finite. A start that already satisfies the
convergence test at ``x0`` runs zero iterations.
The function contains no host synchronisation, so it can itself be
called inside ``jax.jit`` (with the keyword options static) or
``jax.vmap``. It is not meant to be differentiated.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> from braintools.optim import multistart_minimize
>>> def rosenbrock(p):
... return (1. - p['x']) ** 2 + 100. * (p['y'] - p['x'] ** 2) ** 2
>>> x0 = {'x': jnp.array([-1.5, 0., 2.]), 'y': jnp.array([2., -1., 1.])}
>>> res = multistart_minimize(rosenbrock, x0, max_iter=200)
>>> res.converged
Array([ True, True, True], dtype=bool)
>>> print(jnp.round(res.best_x['x'], 3), jnp.round(res.best_x['y'], 3))
1.0 1.0
>>>
>>> res = multistart_minimize(lambda x: jnp.sin(3. * x) + 0.1 * x ** 2,
... jnp.linspace(-2.5, 2.5, 5), bounds=(-3., 3.))
>>> print(jnp.round(res.best_x, 3))
-0.512
"""
if isinstance(max_iter, bool) or not isinstance(max_iter, (int, np.integer)) or max_iter < 1:
raise ValueError(f'`max_iter` must be a positive integer, got {max_iter!r}.')
max_iter = int(max_iter)
opt, is_lbfgs = _make_optimizer(method, learning_rate)
mantissas, units, treedef = _split_leaves(x0)
if not mantissas:
raise ValueError('`x0` has no leaves.')
sizes = {m.shape[0] if m.ndim else None for m in mantissas}
if None in sizes or len(sizes) != 1:
raise ValueError(
'Every leaf of `x0` must have the same leading (start) axis; got shapes '
f'{[m.shape for m in mantissas]}.'
)
dtype = jnp.result_type(*[m.dtype for m in mantissas], jnp.float32)
if not jnp.issubdtype(dtype, jnp.floating):
dtype = brainstate.environ.dftype()
mantissas = [m.astype(dtype) for m in mantissas]
bijectors = _resolve_bijectors(bounds, units, treedef)
def to_x(z_leaves):
return [m if t is None else t.forward(m) for m, t in zip(z_leaves, bijectors)]
def loss(z_leaves):
return _scalar_loss(fun(_join_leaves(to_x(z_leaves), units, treedef))).astype(dtype)
z0 = [m if t is None else t.inverse(_pull_inside(m, t)) for m, t in zip(mantissas, bijectors)]
z0 = [z.astype(dtype) for z in z0]
value_and_grad = jax.value_and_grad(loss)
if is_lbfgs:
cached_value_and_grad = optax.value_and_grad_from_state(loss)
else:
cached_value_and_grad = None
tol_ = jnp.asarray(tol, dtype)
def constrained_grad(z_leaves, g_leaves):
# d f / d x = (d f / d z) / (d x / d z) for the element-wise bijectors;
# a saturated bijector (slope 0) gives an infinite, i.e. unconverged, entry
out = []
for zl, gl, t in zip(z_leaves, g_leaves, bijectors):
if t is None:
out.append(gl)
else:
slope = jax.jvp(t.forward, (zl,), (jnp.ones_like(zl),))[1]
slope = jnp.abs(u.get_mantissa(slope))
out.append(jnp.where(slope > 0, jnp.abs(gl) / jnp.where(slope > 0, slope, 1.), jnp.inf))
return out
def is_converged(z, f, f_prev, g, it):
gx = constrained_grad(z, g)
g_inf = jnp.max(jnp.stack([jnp.max(jnp.abs(l)) for l in gx]))
conv = jnp.isfinite(f) & (g_inf < tol_)
if is_lbfgs:
rel_ok = jnp.abs(f - f_prev) <= tol_ * jnp.abs(f_prev)
conv = conv | (jnp.isfinite(f) & (it > 0) & rel_ok)
return conv
def single(z):
f, g = value_and_grad(z)
state = opt.init(z)
hist = jnp.full((max_iter,), f, dtype)
conv = is_converged(z, f, f, g, 0)
diverged = ~jnp.isfinite(f)
done = conv | diverged
carry0 = (z, state, f, g, jnp.asarray(0, jnp.int32), done, conv, diverged, hist)
def cond(carry):
it, done = carry[4], carry[5]
return (~done) & (it < max_iter)
def body(carry):
z, state, f, g, it, _, _, _, hist = carry
updates, new_state = opt.update(g, state, z, value=f, grad=g, value_fn=loss)
new_z = optax.apply_updates(z, updates)
if is_lbfgs:
new_f, new_g = cached_value_and_grad(new_z, state=new_state)
else:
new_f, new_g = value_and_grad(new_z)
finite = jnp.isfinite(new_f)
conv = finite & is_converged(new_z, new_f, f, new_g, it + 1)
done = ~finite | conv
# keep the last finite point when the loss blows up
keep = lambda new, old: jax.tree.map(lambda a, b: jnp.where(finite, a, b), new, old)
z_out = keep(new_z, z)
f_out = jnp.where(finite, new_f, f)
g_out = keep(new_g, g)
hist = hist.at[it].set(f_out)
return z_out, new_state, f_out, g_out, it + 1, done, conv, ~finite, hist
z, _, f, _, it, _, converged, diverged, hist = jax.lax.while_loop(cond, body, carry0)
# fill the iterations after the stop with the final loss
hist = jnp.where(jnp.arange(max_iter) < it, hist, f)
return z, f, converged, diverged, it, hist
@jax.jit
def run(z0):
return jax.vmap(single)(z0)
z, f, converged, diverged, n_iter, hist = run(z0)
x = _join_leaves(to_x(z), units, treedef)
finite = jnp.isfinite(f)
eligible = finite & ~diverged
eligible = jnp.where(jnp.any(eligible), eligible, finite)
best = jnp.argmin(jnp.where(eligible, f, jnp.inf))
best_x = jax.tree.map(lambda leaf: leaf[best], x, is_leaf=u.math.is_quantity)
return MultiStartResult(
x=x,
fun=f,
converged=converged,
n_iter=n_iter,
best_x=best_x,
best_fun=f[best],
best_index=best,
history=hist,
)