Source code for lyapax.network

"""Coupled, zero-delay networks - wires a dfun + a coupling callable
into the flat ``state -> new_state`` shape ``lyapax.core.lyapunov_spectrum``
expects.

Built on the vendored ring-buffer step (``lyapax.simulator.make_step_fn``,
``has_delays=False``) via a thin carry-to-flat adapter, not a second,
independent Euler/Heun implementation -- the adapter is the single place
that bridges the carry-based step to the flat map
``lyapax.core.lyapunov_spectrum`` wants, so there is only one copy of the
integrators to maintain.

Why this still doesn't need ``lyapax.dde``'s carry-aware tangent engine:
for ``has_delays=False`` the vendored step's ``buf`` never changes (it's a
structural no-op) and ``t`` is never read, so closing over a fixed
placeholder ``buf``/``t`` and only threading ``state`` through
``lyapunov_spectrum``'s ``jacfwd`` is exactly equivalent to differentiating
the whole carry -- there is no delay-buffer tangent information to lose.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Callable

import jax.numpy as jnp

from .core import ODEProblem
from .coupling import CouplingFn
from .simulator import make_step_fn

StepFlat = Callable[[jnp.ndarray], jnp.ndarray]


StepFlatParametrized = Callable[[jnp.ndarray, dict], jnp.ndarray]


[docs] @dataclass(frozen=True) class Network: """Topology for a coupled network: what M3+ call ``weights``/ ``cvar_indices`` (and, for delayed networks, ``delay_steps``), gathered into one named object instead of separate positional arguments repeated across ``network_step``/``network_dde_problem`` call sites. :param weights: ``(n_nodes, n_nodes)``, ``weights[tgt, src]``. :param cvar_indices: indices into the state-variable axis selecting which rows of ``state`` feed the coupling. :param delay_steps: ``None`` for a zero-delay network, a number (``int``, or ``float`` when interpolating -- see ``lyapax.simulator.make_step_fn``) for a single global delay (in units of ``dt``), or an ``(n_nodes, n_nodes)`` integer matrix for per-edge delays. Only relevant to the DDE path (``network_dde_problem``); ignored by ``network_step``. """ weights: jnp.ndarray cvar_indices: tuple[int, ...] delay_steps: int | float | jnp.ndarray | None = None @property def n_nodes(self) -> int: return self.weights.shape[0] @property def n_cvar(self) -> int: return len(self.cvar_indices)
[docs] def make_parametrized_network_step_fn( dfun: Callable, weights: jnp.ndarray, cvar_indices: tuple[int, ...], dt: float, coupling_fn: CouplingFn, integrator: str | Callable = "heun", ) -> StepFlatParametrized: """ Same wiring as ``make_network_step_fn``, except ``params`` is a plain call-time argument, ``(state_flat, params) -> new_state_flat``, instead of being closed over at construction time -- for M6's ``jax.vmap`` parameter sweeps (``lyapax.sweep.sweep_lyapunov_spectrum``), where ``params`` needs to be data the vmapped function can batch over, not a baked-in Python constant. Works because ``lyapax.simulator.make_step_fn`` already threads ``params`` through the scanned carry rather than closing over it (see that function's docstring) -- this adapter was the only piece still closing params in early, so it's the only piece that needed to change. dfun, weights, cvar_indices, coupling_fn, integrator : see ``make_network_step_fn``. """ n_nodes = weights.shape[0] n_cvar = len(cvar_indices) carry_step = make_step_fn( dfun=dfun, weights=weights, has_delays=False, horizon=1, n_nodes=n_nodes, cvar_indices=cvar_indices, dt=dt, coupling_fn=coupling_fn, integrator=integrator, ) buf0 = jnp.zeros((1, n_cvar, n_nodes)) def step_flat(state_flat: jnp.ndarray, params: dict) -> jnp.ndarray: n_sv = state_flat.shape[0] // n_nodes state = state_flat.reshape((n_sv, n_nodes)) (new_state, _new_buf, _t, _params), _ = carry_step( (state, buf0, jnp.int32(0), params), None) return new_state.reshape(-1) return step_flat
[docs] def make_network_step_fn( dfun: Callable, weights: jnp.ndarray, cvar_indices: tuple[int, ...], params: dict, dt: float, coupling_fn: CouplingFn, integrator: str | Callable = "heun", ) -> StepFlat: """ Build a flat ``state_flat (n_sv * n_nodes,) -> new_state_flat`` step function for use with ``lyapax.core.lyapunov_spectrum``. :param dfun: ``(state, coupling, params) -> dstate``, e.g. from ``lyapax.simulator.build_jax_dfun(model_spec)``. ``state`` and ``coupling`` are ``(n_sv, n_nodes)`` / ``(n_cvar, n_nodes)``. :param weights: ``(n_nodes, n_nodes)``, ``weights[tgt, src]``. :param cvar_indices: indices into the state-variable axis selecting which rows of ``state`` feed the coupling (``ModelSpec.cvar_indices``). :param params: closed over for this step function. To sweep over params via ``jax.vmap`` instead, use ``make_parametrized_network_step_fn`` + ``lyapax.sweep.sweep_lyapunov_spectrum`` (M6). :param coupling_fn: see ``lyapax.coupling`` - any callable with signature ``(cvar_state, weights, params) -> coupling`` works, including a user-defined one. :param integrator: ``"euler"``, ``"heun"``, ``"rk4"``, ``"rk6"``, or a callable ``(state, dfun, coupling, dt, params) -> new_state`` -- see ``lyapax.simulator.make_step_fn``. """ step_flat_p = make_parametrized_network_step_fn( dfun, weights, cvar_indices, dt, coupling_fn, integrator) return lambda state_flat: step_flat_p(state_flat, params)
[docs] def network_step( dfun: Callable, network: Network, coupling: CouplingFn, params: dict, dt: float, integrator: str | Callable = "heun", ) -> StepFlat: """ Build a flat ``state_flat -> new_state_flat`` step for a zero-delay coupled network, naming the four concepts ``make_network_step_fn`` otherwise mixes into one long argument list separately: model dynamics (``dfun``), topology (``network``), the coupling rule (``coupling``), and the numerical method (``integrator``). A thin wrapper -- it calls straight through to ``make_network_step_fn``, reading ``weights``/``cvar_indices`` off ``network``. Prefer this over ``make_network_step_fn`` in new code; the latter remains for direct, lower-level use. :param dfun: ``(state, coupling, params) -> dstate``, e.g. from ``lyapax.simulator.build_jax_dfun(model_spec)``. :param network: topology, see ``Network``. :param coupling: any ``lyapax.coupling``-style callable, ``(cvar_state, weights, params) -> coupling``. :param params: closed over for this step function. :param dt: fixed step size. :param integrator: ``"euler"``, ``"heun"``, ``"rk4"``, ``"rk6"``, or a callable -- see ``lyapax.simulator.make_step_fn``. """ return make_network_step_fn( dfun=dfun, weights=network.weights, cvar_indices=network.cvar_indices, params=params, dt=dt, coupling_fn=coupling, integrator=integrator, )
[docs] def network_step_parametrized( dfun: Callable, network: Network, coupling: CouplingFn, dt: float, integrator: str | Callable = "heun", ) -> StepFlatParametrized: """``network_step``'s counterpart for ``jax.vmap`` parameter sweeps -- see ``make_parametrized_network_step_fn``, which this wraps.""" return make_parametrized_network_step_fn( dfun=dfun, weights=network.weights, cvar_indices=network.cvar_indices, dt=dt, coupling_fn=coupling, integrator=integrator, )
[docs] def network_problem( dfun: Callable, network: Network, coupling: CouplingFn, params: dict, state0: jnp.ndarray, dt: float, integrator: str | Callable = "heun", ) -> ODEProblem: """ Build an ``ODEProblem`` for a zero-delay coupled network -- bundles ``network_step``'s ``step_fn`` together with ``state0`` and ``dt``, the same ``lyapunov_spectrum(problem, n_steps=...)`` recipe ``ode_problem`` gives the plain-ODE case (see ``ODEProblem``). :param dfun: ``(state, coupling, params) -> dstate``. :param network: topology, see ``Network``. :param coupling: any ``lyapax.coupling``-style callable, ``(cvar_state, weights, params) -> coupling``. :param params: closed over for this step function. :param state0: initial state, either already flat (``n_sv * n_nodes,``) or in the network's natural ``(n_sv, n_nodes)`` shape -- flattened here either way, since ``network_step``'s step function operates on flat state. :param dt: fixed step size. :param integrator: ``"euler"``, ``"heun"``, ``"rk4"``, ``"rk6"``, or a callable -- see ``lyapax.simulator.make_step_fn``. Examples -------- >>> import jax >>> jax.config.update("jax_enable_x64", True) >>> import jax.numpy as jnp >>> from lyapax.core import lyapunov_spectrum >>> from lyapax.coupling import linear_coupling >>> from lyapax.network import Network, network_problem >>> from lyapax.simulator import ModelSpec, StateVar, Parameter, build_jax_dfun >>> weights = jnp.array([[0., 1., 0., 1.], ... [1., 0., 1., 0.], ... [0., 1., 0., 1.], ... [1., 0., 1., 0.]]) # 4-cycle graph >>> model = ModelSpec( ... name="linear_node", ... state_variables=(StateVar("x", default_init=0.0),), ... parameters=(Parameter("gamma", -2.0),), ... cvar=("x",), ... dfun_str={"x": "gamma * x + c"}, ... ) >>> dfun = build_jax_dfun(model) >>> network = Network(weights=weights, cvar_indices=model.cvar_indices) >>> problem = network_problem( ... dfun, network, linear_coupling(a=1.0, b=0.0), ... params={"gamma": -2.0, "G": 0.5}, ... state0=jnp.array([0.3, -0.1, 0.2, -0.4]), dt=1e-3, ... ) >>> result = lyapunov_spectrum(problem, n_steps=20_000, renorm_every=10) >>> result.exponents # doctest: +SKIP Array([-1., -2., -2., -3.], dtype=float64) """ step_fn = network_step(dfun, network, coupling, params, dt, integrator=integrator) state0 = jnp.asarray(state0).reshape(-1) return ODEProblem(step_fn=step_fn, state0=state0, dt=dt)