aura-optax 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ # Build artefacts
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+
6
+ # Python caches
7
+ __pycache__/
8
+ *.py[cod]
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Enrico Ballini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: aura-optax
3
+ Version: 0.1.0
4
+ Summary: AURA (Angular Update Rate Adaptation): a per-parameter step-size multiplier for Adam and Muon on complex-valued and real-valued parameters, as Optax gradient transformations
5
+ Project-URL: Repository, https://github.com/enricoballini/aura-optax
6
+ Project-URL: Issues, https://github.com/enricoballini/aura-optax/issues
7
+ Author: Enrico Ballini
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: adam,complex-valued neural networks,jax,muon,optax,optimizer,step size
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: jax>=0.5.3
18
+ Requires-Dist: optax>=0.2.7
19
+ Provides-Extra: test
20
+ Requires-Dist: numpy; extra == 'test'
21
+ Requires-Dist: pytest>=7; extra == 'test'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # aura-optax
25
+
26
+ AURA (Angular Update Rate Adaptation) for [Optax](https://github.com/google-deepmind/optax).
27
+
28
+ AURA scales the update direction of a base optimizer by a per-parameter multiplier γ, which increases when consecutive directions agree in the complex plane and decreases when they do not. It is designed for complex-valued neural networks and applies to real-valued parameters as well. The package provides three `optax.GradientTransformation`s:
29
+
30
+ - `aura_optax.scale_by_aura`: AURA's multiplier alone, to be chained after the direction of any Optax optimizer;
31
+ - `aura_optax.adam_aura`: the Adam direction, scaled by AURA's multiplier, with decoupled weight decay;
32
+ - `aura_optax.muon_aura`: the Muon direction on weight matrices and the Adam direction on all other parameters, scaled by AURA's multiplier, with decoupled weight decay.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install aura-optax
38
+ ```
39
+
40
+ The package requires Python ≥ 3.10, JAX and Optax ≥ 0.2.7. For GPU support, install the JAX build for your platform first, following the [JAX installation guide](https://docs.jax.dev/en/latest/installation.html).
41
+
42
+ ## Usage
43
+
44
+ `scale_by_aura` is a gradient transformation that multiplies each update by γ. It is placed in an `optax.chain` after the transformation that produces the direction (`optax.scale_by_adam`, `optax.contrib.scale_by_muon`, ...) and before the learning rate:
45
+
46
+ ```python
47
+ import optax
48
+
49
+ import aura_optax
50
+
51
+ optimizer = optax.chain(
52
+ optax.scale_by_adam(), # the direction of any Optax optimizer
53
+ aura_optax.scale_by_aura(), # AURA's multiplier
54
+ optax.add_decayed_weights(1e-4), # optional decoupled weight decay
55
+ optax.scale_by_learning_rate(1e-2),
56
+ )
57
+ ```
58
+
59
+ The complete training loop below fits a complex-valued linear model by least squares with this optimizer:
60
+
61
+ ```python
62
+ import jax
63
+ import jax.numpy as jnp
64
+ import optax
65
+
66
+ import aura_optax
67
+
68
+ key_x, key_w, key_b, key_init = jax.random.split(jax.random.key(0), 4)
69
+ x = jax.random.normal(key_x, (256, 8), dtype=jnp.complex64)
70
+ y = (
71
+ x @ jax.random.normal(key_w, (4, 8), dtype=jnp.complex64).T
72
+ + jax.random.normal(key_b, (4,), dtype=jnp.complex64)
73
+ )
74
+ params = {
75
+ "w": 0.1 * jax.random.normal(key_init, (4, 8), dtype=jnp.complex64),
76
+ "b": jnp.zeros(4, dtype=jnp.complex64),
77
+ }
78
+
79
+
80
+ def loss_fn(params):
81
+ residual = x @ params["w"].T + params["b"] - y
82
+ return jnp.mean(jnp.real(residual * jnp.conj(residual)))
83
+
84
+
85
+ optimizer = optax.chain(
86
+ optax.scale_by_adam(),
87
+ aura_optax.scale_by_aura(),
88
+ optax.add_decayed_weights(1e-4),
89
+ optax.scale_by_learning_rate(1e-2),
90
+ )
91
+ opt_state = optimizer.init(params)
92
+
93
+
94
+ @jax.jit
95
+ def train_step(params, opt_state):
96
+ loss, grads = jax.value_and_grad(loss_fn)(params)
97
+ grads = jax.tree.map(jnp.conj, grads) # see the note below
98
+ updates, opt_state = optimizer.update(grads, opt_state, params)
99
+ return optax.apply_updates(params, updates), opt_state, loss
100
+
101
+
102
+ for step in range(1000):
103
+ params, opt_state, loss = train_step(params, opt_state)
104
+ print(f"final loss: {loss:.3e}")
105
+ ```
106
+
107
+ **Complex gradients.** For a real-valued loss L of complex parameters w = x + iy, `jax.grad` returns ∂L/∂x − i ∂L/∂y, which is the complex conjugate of the gradient g = ∂L/∂x + i ∂L/∂y whose negative is the steepest-descent direction. The gradients must therefore be conjugated before `optimizer.update`, as in the example above. The same conjugation is required by every Optax optimizer applied to complex parameters, and it leaves real-valued parameters unchanged.
108
+
109
+ **Ready-made optimizers.** `adam_aura` is the chain above; `muon_aura` applies AURA to the Muon direction on the 2-D parameters and to the Adam direction on all others, with its own gate values. Both take the learning rate as a scalar or an Optax schedule:
110
+
111
+ ```python
112
+ optimizer = aura_optax.adam_aura(learning_rate=1e-2)
113
+ optimizer = aura_optax.muon_aura(learning_rate=1e-2)
114
+ ```
115
+
116
+ **Monitoring the multiplier.** AURA's state is the entry of `opt_state` at the position of `scale_by_aura` in the chain: `opt_state[1]` in the chain above and in `adam_aura`, `opt_state[2]` in `muon_aura`:
117
+
118
+ ```python
119
+ gamma = opt_state[1].multiplier # same tree structure as params
120
+ ```
121
+
122
+ ## Method
123
+ TODO
124
+
125
+ ## License
126
+
127
+ MIT; see [LICENSE](LICENSE).
@@ -0,0 +1,104 @@
1
+ # aura-optax
2
+
3
+ AURA (Angular Update Rate Adaptation) for [Optax](https://github.com/google-deepmind/optax).
4
+
5
+ AURA scales the update direction of a base optimizer by a per-parameter multiplier γ, which increases when consecutive directions agree in the complex plane and decreases when they do not. It is designed for complex-valued neural networks and applies to real-valued parameters as well. The package provides three `optax.GradientTransformation`s:
6
+
7
+ - `aura_optax.scale_by_aura`: AURA's multiplier alone, to be chained after the direction of any Optax optimizer;
8
+ - `aura_optax.adam_aura`: the Adam direction, scaled by AURA's multiplier, with decoupled weight decay;
9
+ - `aura_optax.muon_aura`: the Muon direction on weight matrices and the Adam direction on all other parameters, scaled by AURA's multiplier, with decoupled weight decay.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install aura-optax
15
+ ```
16
+
17
+ The package requires Python ≥ 3.10, JAX and Optax ≥ 0.2.7. For GPU support, install the JAX build for your platform first, following the [JAX installation guide](https://docs.jax.dev/en/latest/installation.html).
18
+
19
+ ## Usage
20
+
21
+ `scale_by_aura` is a gradient transformation that multiplies each update by γ. It is placed in an `optax.chain` after the transformation that produces the direction (`optax.scale_by_adam`, `optax.contrib.scale_by_muon`, ...) and before the learning rate:
22
+
23
+ ```python
24
+ import optax
25
+
26
+ import aura_optax
27
+
28
+ optimizer = optax.chain(
29
+ optax.scale_by_adam(), # the direction of any Optax optimizer
30
+ aura_optax.scale_by_aura(), # AURA's multiplier
31
+ optax.add_decayed_weights(1e-4), # optional decoupled weight decay
32
+ optax.scale_by_learning_rate(1e-2),
33
+ )
34
+ ```
35
+
36
+ The complete training loop below fits a complex-valued linear model by least squares with this optimizer:
37
+
38
+ ```python
39
+ import jax
40
+ import jax.numpy as jnp
41
+ import optax
42
+
43
+ import aura_optax
44
+
45
+ key_x, key_w, key_b, key_init = jax.random.split(jax.random.key(0), 4)
46
+ x = jax.random.normal(key_x, (256, 8), dtype=jnp.complex64)
47
+ y = (
48
+ x @ jax.random.normal(key_w, (4, 8), dtype=jnp.complex64).T
49
+ + jax.random.normal(key_b, (4,), dtype=jnp.complex64)
50
+ )
51
+ params = {
52
+ "w": 0.1 * jax.random.normal(key_init, (4, 8), dtype=jnp.complex64),
53
+ "b": jnp.zeros(4, dtype=jnp.complex64),
54
+ }
55
+
56
+
57
+ def loss_fn(params):
58
+ residual = x @ params["w"].T + params["b"] - y
59
+ return jnp.mean(jnp.real(residual * jnp.conj(residual)))
60
+
61
+
62
+ optimizer = optax.chain(
63
+ optax.scale_by_adam(),
64
+ aura_optax.scale_by_aura(),
65
+ optax.add_decayed_weights(1e-4),
66
+ optax.scale_by_learning_rate(1e-2),
67
+ )
68
+ opt_state = optimizer.init(params)
69
+
70
+
71
+ @jax.jit
72
+ def train_step(params, opt_state):
73
+ loss, grads = jax.value_and_grad(loss_fn)(params)
74
+ grads = jax.tree.map(jnp.conj, grads) # see the note below
75
+ updates, opt_state = optimizer.update(grads, opt_state, params)
76
+ return optax.apply_updates(params, updates), opt_state, loss
77
+
78
+
79
+ for step in range(1000):
80
+ params, opt_state, loss = train_step(params, opt_state)
81
+ print(f"final loss: {loss:.3e}")
82
+ ```
83
+
84
+ **Complex gradients.** For a real-valued loss L of complex parameters w = x + iy, `jax.grad` returns ∂L/∂x − i ∂L/∂y, which is the complex conjugate of the gradient g = ∂L/∂x + i ∂L/∂y whose negative is the steepest-descent direction. The gradients must therefore be conjugated before `optimizer.update`, as in the example above. The same conjugation is required by every Optax optimizer applied to complex parameters, and it leaves real-valued parameters unchanged.
85
+
86
+ **Ready-made optimizers.** `adam_aura` is the chain above; `muon_aura` applies AURA to the Muon direction on the 2-D parameters and to the Adam direction on all others, with its own gate values. Both take the learning rate as a scalar or an Optax schedule:
87
+
88
+ ```python
89
+ optimizer = aura_optax.adam_aura(learning_rate=1e-2)
90
+ optimizer = aura_optax.muon_aura(learning_rate=1e-2)
91
+ ```
92
+
93
+ **Monitoring the multiplier.** AURA's state is the entry of `opt_state` at the position of `scale_by_aura` in the chain: `opt_state[1]` in the chain above and in `adam_aura`, `opt_state[2]` in `muon_aura`:
94
+
95
+ ```python
96
+ gamma = opt_state[1].multiplier # same tree structure as params
97
+ ```
98
+
99
+ ## Method
100
+ TODO
101
+
102
+ ## License
103
+
104
+ MIT; see [LICENSE](LICENSE).
@@ -0,0 +1,8 @@
1
+ """AURA (Angular Update Rate Adaptation) optimizers for Optax."""
2
+
3
+ from aura_optax._alias import adam_aura, muon_aura
4
+ from aura_optax._aura import AuraState, scale_by_aura
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["AuraState", "adam_aura", "muon_aura", "scale_by_aura", "__version__"]
@@ -0,0 +1,105 @@
1
+ """Adam-AURA and Muon-AURA optimizers."""
2
+
3
+ from typing import Any, Callable
4
+
5
+ import optax
6
+
7
+ from aura_optax._aura import scale_by_aura
8
+ from aura_optax._muon import muon_hybrid_direction
9
+
10
+
11
+ def adam_aura(
12
+ learning_rate: optax.ScalarOrSchedule,
13
+ *,
14
+ b1: float = 0.9,
15
+ b2: float = 0.999,
16
+ eps: float = 1e-8,
17
+ beta_zeta: float = 0.95,
18
+ epsilon_e: float = 1e-6,
19
+ chi_alignment: float = 0.7,
20
+ chi_opposition: float = 0.4,
21
+ psi_alignment: float = 0.015,
22
+ psi_opposition: float = 0.3,
23
+ eta_minus: float = 0.99,
24
+ eta_plus: float = 1.01,
25
+ gamma_min: float = 1e-3,
26
+ gamma_max: float = 1e3,
27
+ gamma_init: float = 1.0,
28
+ weight_decay: float = 1e-4,
29
+ mask: Any | Callable[[optax.Params], Any] | None = None,
30
+ ) -> optax.GradientTransformation:
31
+ """Adam scaled by AURA's multiplier, with decoupled weight decay; AURA's state is state[1]."""
32
+ return optax.chain(
33
+ optax.scale_by_adam(b1=b1, b2=b2, eps=eps, nesterov=False),
34
+ scale_by_aura(
35
+ beta_zeta=beta_zeta,
36
+ epsilon_e=epsilon_e,
37
+ chi_alignment=chi_alignment,
38
+ chi_opposition=chi_opposition,
39
+ psi_alignment=psi_alignment,
40
+ psi_opposition=psi_opposition,
41
+ eta_minus=eta_minus,
42
+ eta_plus=eta_plus,
43
+ gamma_min=gamma_min,
44
+ gamma_max=gamma_max,
45
+ gamma_init=gamma_init,
46
+ ),
47
+ optax.add_decayed_weights(weight_decay, mask),
48
+ optax.scale_by_learning_rate(learning_rate),
49
+ )
50
+
51
+
52
+ def muon_aura(
53
+ learning_rate: optax.ScalarOrSchedule,
54
+ *,
55
+ ns_steps: int = 5,
56
+ beta: float = 0.95,
57
+ nesterov: bool = True,
58
+ adam_b1: float = 0.9,
59
+ adam_b2: float = 0.999,
60
+ eps: float = 1e-8,
61
+ matrix_lr_scale: float = 10.0,
62
+ beta_zeta: float = 0.95,
63
+ epsilon_e: float = 1e-6,
64
+ chi_alignment: float = 0.75,
65
+ chi_opposition: float = 0.4,
66
+ psi_alignment: float = 0.01,
67
+ psi_opposition: float = 0.2,
68
+ eta_minus: float = 0.99,
69
+ eta_plus: float = 1.01,
70
+ gamma_min: float = 1e-3,
71
+ gamma_max: float = 1e3,
72
+ gamma_init: float = 1.0,
73
+ weight_decay: float = 1e-4,
74
+ mask: Any | Callable[[optax.Params], Any] | None = None,
75
+ ) -> optax.GradientTransformation:
76
+ """Muon (2-D parameters) or Adam (others) scaled by AURA's multiplier; AURA's state is state[2]."""
77
+ matrix_direction, bias_direction = muon_hybrid_direction(
78
+ ns_steps=ns_steps,
79
+ beta=beta,
80
+ nesterov=nesterov,
81
+ adam_b1=adam_b1,
82
+ adam_b2=adam_b2,
83
+ eps=eps,
84
+ matrix_lr_scale=matrix_lr_scale,
85
+ )
86
+ # The two direction stages stay flat (not a nested chain) so that AuraState is state[2].
87
+ return optax.chain(
88
+ matrix_direction,
89
+ bias_direction,
90
+ scale_by_aura(
91
+ beta_zeta=beta_zeta,
92
+ epsilon_e=epsilon_e,
93
+ chi_alignment=chi_alignment,
94
+ chi_opposition=chi_opposition,
95
+ psi_alignment=psi_alignment,
96
+ psi_opposition=psi_opposition,
97
+ eta_minus=eta_minus,
98
+ eta_plus=eta_plus,
99
+ gamma_min=gamma_min,
100
+ gamma_max=gamma_max,
101
+ gamma_init=gamma_init,
102
+ ),
103
+ optax.add_decayed_weights(weight_decay, mask),
104
+ optax.scale_by_learning_rate(learning_rate),
105
+ )
@@ -0,0 +1,110 @@
1
+ """AURA's per-parameter step-size multiplier as an Optax gradient transformation."""
2
+
3
+ from typing import NamedTuple
4
+
5
+ import jax
6
+ import jax.numpy as jnp
7
+ from jax.tree_util import tree_map
8
+ import optax
9
+
10
+
11
+ class AuraState(NamedTuple):
12
+ """State of scale_by_aura; chi + 1j * psi = zeta_ema / (1 - beta_zeta**count)."""
13
+
14
+ count: jax.Array
15
+ previous_direction: optax.Updates
16
+ zeta_ema: optax.Updates
17
+ multiplier: optax.Updates
18
+
19
+
20
+ def scale_by_aura(
21
+ *,
22
+ beta_zeta: float = 0.95,
23
+ epsilon_e: float = 1e-6,
24
+ chi_alignment: float = 0.7,
25
+ chi_opposition: float = 0.4,
26
+ psi_alignment: float = 0.015,
27
+ psi_opposition: float = 0.3,
28
+ eta_minus: float = 0.99,
29
+ eta_plus: float = 1.01,
30
+ gamma_min: float = 1e-3,
31
+ gamma_max: float = 1e3,
32
+ gamma_init: float = 1.0,
33
+ ) -> optax.GradientTransformation:
34
+ """Scale each update by AURA's multiplier; chain after the direction, before the learning rate."""
35
+ if not 0.0 <= beta_zeta < 1.0:
36
+ raise ValueError("beta_zeta must be in [0, 1)")
37
+ if epsilon_e <= 0.0:
38
+ raise ValueError("epsilon_e must be positive")
39
+ if not -1.0 <= chi_opposition < chi_alignment <= 1.0:
40
+ raise ValueError(
41
+ "chi_opposition and chi_alignment must satisfy "
42
+ "-1 <= chi_opposition < chi_alignment <= 1"
43
+ )
44
+ if not 0.0 <= psi_alignment < psi_opposition <= 1.0:
45
+ raise ValueError(
46
+ "psi_alignment and psi_opposition must satisfy "
47
+ "0 <= psi_alignment < psi_opposition <= 1"
48
+ )
49
+ if not 0.0 < eta_minus < 1.0:
50
+ raise ValueError("eta_minus must be in (0, 1)")
51
+ if eta_plus <= 1.0:
52
+ raise ValueError("eta_plus must exceed 1")
53
+ if not 0.0 < gamma_min <= 1.0:
54
+ raise ValueError("gamma_min must be in (0, 1]")
55
+ if gamma_max < 1.0:
56
+ raise ValueError("gamma_max must be at least 1")
57
+ if not gamma_min <= gamma_init <= gamma_max:
58
+ raise ValueError("gamma_init must be in [gamma_min, gamma_max]")
59
+
60
+ def init_fn(params):
61
+ zeros = tree_map(jnp.zeros_like, params)
62
+ multiplier = tree_map(
63
+ lambda param: jnp.full_like(jnp.real(param), gamma_init), params
64
+ )
65
+ return AuraState(
66
+ count=jnp.zeros((), dtype=jnp.int32),
67
+ previous_direction=zeros,
68
+ zeta_ema=zeros,
69
+ multiplier=multiplier,
70
+ )
71
+
72
+ def update_fn(updates, state, params=None):
73
+ del params
74
+ count = state.count + 1
75
+ bias_correction = 1.0 - beta_zeta**count
76
+
77
+ def average_consistency(direction, previous_direction, zeta_ema_previous):
78
+ z = direction * jnp.conj(previous_direction)
79
+ energy = jnp.abs(direction) ** 2 + jnp.abs(previous_direction) ** 2
80
+ zeta = 2.0 * z / (energy + epsilon_e)
81
+ return beta_zeta * zeta_ema_previous + (1.0 - beta_zeta) * zeta
82
+
83
+ def next_multiplier(gamma, zeta_ema_value):
84
+ zeta_hat = zeta_ema_value / bias_correction
85
+ chi = jnp.real(zeta_hat)
86
+ psi = jnp.imag(zeta_hat)
87
+ shrink = jnp.logical_or(chi <= chi_opposition, jnp.abs(psi) >= psi_opposition)
88
+ grow = jnp.logical_and(
89
+ jnp.logical_not(shrink),
90
+ jnp.logical_and(chi >= chi_alignment, jnp.abs(psi) <= psi_alignment),
91
+ )
92
+ return jnp.where(
93
+ shrink,
94
+ jnp.maximum(eta_minus * gamma, gamma_min),
95
+ jnp.where(grow, jnp.minimum(eta_plus * gamma, gamma_max), gamma),
96
+ )
97
+
98
+ zeta_ema = tree_map(
99
+ average_consistency, updates, state.previous_direction, state.zeta_ema
100
+ )
101
+ multiplier = tree_map(next_multiplier, state.multiplier, zeta_ema)
102
+ scaled = tree_map(lambda direction, gamma: gamma * direction, updates, multiplier)
103
+ return scaled, AuraState(
104
+ count=count,
105
+ previous_direction=updates,
106
+ zeta_ema=zeta_ema,
107
+ multiplier=multiplier,
108
+ )
109
+
110
+ return optax.GradientTransformation(init_fn, update_fn)
@@ -0,0 +1,53 @@
1
+ """Muon-AURA's direction: Muon on 2-D parameters, Adam on all others."""
2
+
3
+ import jax.numpy as jnp
4
+ from jax.tree_util import tree_map
5
+ import optax
6
+
7
+
8
+ def muon_hybrid_direction(
9
+ *,
10
+ ns_steps: int,
11
+ beta: float,
12
+ nesterov: bool,
13
+ adam_b1: float,
14
+ adam_b2: float,
15
+ eps: float,
16
+ matrix_lr_scale: float,
17
+ ) -> tuple[optax.GradientTransformation, optax.GradientTransformation]:
18
+ """The masked Muon (2-D leaves) and Adam (other leaves) stages, returned unchained."""
19
+ is_matrix = lambda tree: tree_map(lambda leaf: leaf.ndim == 2, tree)
20
+ is_not_matrix = lambda tree: tree_map(lambda leaf: leaf.ndim != 2, tree)
21
+ # Masked-away leaves are childless MaskedNode sentinels, which tree_map skips.
22
+ matrix_dimension_numbers = lambda tree: tree_map(
23
+ lambda leaf: optax.contrib.MuonDimensionNumbers(), tree
24
+ )
25
+ matrix_direction = optax.masked(
26
+ optax.chain(
27
+ # Needs Optax >= 0.2.7: earlier scale_by_muon rescales by shape itself and uses x.T, not x.T.conj().
28
+ optax.contrib.scale_by_muon(
29
+ ns_steps=ns_steps,
30
+ beta=beta,
31
+ eps=eps,
32
+ nesterov=nesterov,
33
+ weight_dimension_numbers=matrix_dimension_numbers,
34
+ ),
35
+ # optax.contrib.muon's default width scaling, sqrt(max(1, fan_out / fan_in)).
36
+ optax.stateless(
37
+ lambda updates, params: tree_map(
38
+ lambda leaf: jnp.sqrt(
39
+ jnp.maximum(1.0, leaf.shape[1] / leaf.shape[0])
40
+ )
41
+ * leaf,
42
+ updates,
43
+ )
44
+ ),
45
+ optax.scale(matrix_lr_scale),
46
+ ),
47
+ is_matrix,
48
+ )
49
+ bias_direction = optax.masked(
50
+ optax.scale_by_adam(b1=adam_b1, b2=adam_b2, eps=eps, nesterov=False),
51
+ is_not_matrix,
52
+ )
53
+ return matrix_direction, bias_direction
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aura-optax"
7
+ dynamic = ["version"]
8
+ description = "AURA (Angular Update Rate Adaptation): a per-parameter step-size multiplier for Adam and Muon on complex-valued and real-valued parameters, as Optax gradient transformations"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Enrico Ballini" }]
14
+ keywords = ["jax", "optax", "optimizer", "complex-valued neural networks", "adam", "muon", "step size"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+ dependencies = [
23
+ "jax>=0.5.3",
24
+ "optax>=0.2.7",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/enricoballini/aura-optax"
29
+ Issues = "https://github.com/enricoballini/aura-optax/issues"
30
+
31
+ [project.optional-dependencies]
32
+ test = ["pytest>=7", "numpy"]
33
+
34
+ [tool.hatch.version]
35
+ path = "aura_optax/__init__.py"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["aura_optax"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["aura_optax", "tests", "README.md", "LICENSE", "pyproject.toml"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
@@ -0,0 +1,197 @@
1
+ """ """
2
+
3
+ import cmath
4
+
5
+ import jax
6
+ import jax.numpy as jnp
7
+ import numpy as np
8
+ import optax
9
+ import pytest
10
+
11
+ import aura_optax
12
+
13
+ ETA_MINUS = 0.99
14
+ ETA_PLUS = 1.01
15
+
16
+
17
+ def complex_array(rng, *shape):
18
+ return jnp.asarray(
19
+ rng.standard_normal(shape) + 1j * rng.standard_normal(shape),
20
+ dtype=jnp.complex64,
21
+ )
22
+
23
+
24
+ def complex_params(seed=0):
25
+ rng = np.random.default_rng(seed)
26
+ return {
27
+ "w1": complex_array(rng, 8, 4),
28
+ "b1": complex_array(rng, 8),
29
+ "w2": complex_array(rng, 3, 8),
30
+ "b2": complex_array(rng, 3),
31
+ }
32
+
33
+
34
+ def final_multiplier(directions):
35
+ """Multiplier after feeding directions to scale_by_aura with its defaults."""
36
+ transform = aura_optax.scale_by_aura()
37
+ state = transform.init(directions[0])
38
+ for direction in directions:
39
+ output, state = transform.update(direction, state)
40
+ np.testing.assert_allclose(
41
+ output, state.multiplier * direction, rtol=1e-6, equal_nan=False
42
+ )
43
+ return state.multiplier
44
+
45
+
46
+ def test_first_step_decreases_multiplier():
47
+ # d_0 = 0 gives chi_1 = 0 <= chi_opposition.
48
+ direction = complex_array(np.random.default_rng(0), 5)
49
+ np.testing.assert_allclose(final_multiplier([direction]), ETA_MINUS, rtol=1e-6)
50
+
51
+
52
+ @pytest.mark.parametrize("dtype", [jnp.complex64, jnp.float32])
53
+ def test_constant_direction_increases_multiplier(dtype):
54
+ # chi_t = (1 - 0.95^(t-1)) / (1 - 0.95^t): decrease at t = 1, no change at t = 2, 3, increase after.
55
+ rng = np.random.default_rng(0)
56
+ if dtype == jnp.float32:
57
+ direction = jnp.asarray(rng.standard_normal(5), dtype=dtype)
58
+ else:
59
+ direction = complex_array(rng, 5)
60
+ multiplier = final_multiplier([direction] * 50)
61
+ assert multiplier.dtype == jnp.float32
62
+ np.testing.assert_allclose(multiplier, ETA_MINUS * ETA_PLUS**47, rtol=1e-4)
63
+
64
+
65
+ def test_opposite_directions_decrease_multiplier():
66
+ direction = complex_array(np.random.default_rng(0), 5)
67
+ multiplier = final_multiplier([direction * (-1) ** t for t in range(20)])
68
+ np.testing.assert_allclose(multiplier, ETA_MINUS**20, rtol=1e-4)
69
+
70
+
71
+ def test_rotation_decreases_multiplier_despite_alignment():
72
+ # chi stays near cos(0.5) = 0.88, but |psi| >= psi_opposition from t = 3; t = 2 changes nothing.
73
+ direction = complex_array(np.random.default_rng(0), 5)
74
+ directions = [direction * cmath.exp(0.5j * t) for t in range(30)]
75
+ np.testing.assert_allclose(final_multiplier(directions), ETA_MINUS**29, rtol=1e-4)
76
+
77
+
78
+ def test_adam_aura_with_fixed_multiplier_equals_adamw():
79
+ params = complex_params()
80
+ rng = np.random.default_rng(1)
81
+ gradients = [
82
+ jax.tree.map(lambda p: complex_array(rng, *p.shape), params) for _ in range(10)
83
+ ]
84
+ aura = aura_optax.adam_aura(1e-2, gamma_min=1.0, gamma_max=1.0)
85
+ adamw = optax.adamw(1e-2, weight_decay=1e-4)
86
+ aura_params, aura_state = params, aura.init(params)
87
+ adamw_params, adamw_state = params, adamw.init(params)
88
+ for grads in gradients:
89
+ aura_updates, aura_state = aura.update(grads, aura_state, aura_params)
90
+ adamw_updates, adamw_state = adamw.update(grads, adamw_state, adamw_params)
91
+ aura_params = optax.apply_updates(aura_params, aura_updates)
92
+ adamw_params = optax.apply_updates(adamw_params, adamw_updates)
93
+ jax.tree.map(
94
+ lambda a, b: np.testing.assert_allclose(a, b, rtol=1e-6, atol=0, equal_nan=False),
95
+ aura_params,
96
+ adamw_params,
97
+ )
98
+
99
+
100
+ @pytest.fixture
101
+ def x64():
102
+ jax.config.update("jax_enable_x64", True)
103
+ yield
104
+ jax.config.update("jax_enable_x64", False)
105
+
106
+
107
+ @pytest.mark.parametrize("seed", [0, 1])
108
+ def test_muon_aura_is_phase_equivariant(seed, x64):
109
+ # For the matrices this holds only if Newton-Schulz uses the conjugate transpose.
110
+ # Runs in complex128: the Newton-Schulz steps amplify complex64 rounding to ~1e-1.
111
+ params = jax.tree.map(lambda p: p.astype(jnp.complex128), complex_params(seed))
112
+ rng = np.random.default_rng(seed + 10)
113
+ grads = jax.tree.map(
114
+ lambda p: complex_array(rng, *p.shape).astype(jnp.complex128), params
115
+ )
116
+ phase = cmath.exp(0.7j)
117
+ rotated = jax.tree.map(lambda g: phase * g, grads)
118
+ optimizer = aura_optax.muon_aura(1.0, weight_decay=0.0)
119
+ state = optimizer.init(params)
120
+ updates, _ = optimizer.update(grads, state, params)
121
+ rotated_updates, _ = optimizer.update(rotated, state, params)
122
+ jax.tree.map(
123
+ lambda u, r: np.testing.assert_allclose(
124
+ r, phase * u, rtol=1e-9, atol=1e-10, equal_nan=False
125
+ ),
126
+ updates,
127
+ rotated_updates,
128
+ )
129
+
130
+
131
+ @pytest.mark.parametrize(
132
+ "make_optimizer", [aura_optax.adam_aura, aura_optax.muon_aura], ids=["adam", "muon"]
133
+ )
134
+ def test_training_reduces_loss(make_optimizer):
135
+ rng = np.random.default_rng(0)
136
+ x = complex_array(rng, 64, 4)
137
+ y = x @ complex_array(rng, 3, 4).T + complex_array(rng, 3)
138
+ params = {"w": 0.1 * complex_array(rng, 3, 4), "b": jnp.zeros(3, jnp.complex64)}
139
+
140
+ def loss_fn(params):
141
+ residual = x @ params["w"].T + params["b"] - y
142
+ return jnp.mean(jnp.real(residual * jnp.conj(residual)))
143
+
144
+ optimizer = make_optimizer(1e-2)
145
+
146
+ @jax.jit
147
+ def step(params, state):
148
+ grads = jax.tree.map(jnp.conj, jax.grad(loss_fn)(params))
149
+ updates, state = optimizer.update(grads, state, params)
150
+ return optax.apply_updates(params, updates), state
151
+
152
+ state = optimizer.init(params)
153
+ initial_loss = float(loss_fn(params))
154
+ for _ in range(500):
155
+ params, state = step(params, state)
156
+ assert float(loss_fn(params)) < 1e-2 * initial_loss
157
+
158
+
159
+ def test_schedule_and_jit():
160
+ params = complex_params()
161
+ grads = complex_params(seed=5)
162
+ schedule = optax.linear_schedule(1e-2, 1e-3, transition_steps=10)
163
+ for optimizer in (aura_optax.adam_aura(schedule), aura_optax.muon_aura(schedule)):
164
+ state = optimizer.init(params)
165
+ update = jax.jit(optimizer.update)
166
+ for _ in range(3):
167
+ updates, state = update(grads, state, params)
168
+ for leaf, param in zip(jax.tree.leaves(updates), jax.tree.leaves(params)):
169
+ assert leaf.shape == param.shape and leaf.dtype == param.dtype
170
+ assert bool(jnp.all(jnp.isfinite(jnp.abs(leaf))))
171
+
172
+
173
+ def test_aura_state_position_in_chain():
174
+ params = complex_params()
175
+ adam_state = aura_optax.adam_aura(1e-3).init(params)
176
+ muon_state = aura_optax.muon_aura(1e-3).init(params)
177
+ assert isinstance(adam_state[1], aura_optax.AuraState)
178
+ assert isinstance(muon_state[2], aura_optax.AuraState)
179
+
180
+
181
+ @pytest.mark.parametrize(
182
+ "kwargs",
183
+ [
184
+ {"beta_zeta": 1.0},
185
+ {"epsilon_e": 0.0},
186
+ {"chi_opposition": 0.8, "chi_alignment": 0.7},
187
+ {"psi_alignment": 0.3, "psi_opposition": 0.3},
188
+ {"eta_minus": 1.0},
189
+ {"eta_plus": 1.0},
190
+ {"gamma_min": 0.0},
191
+ {"gamma_max": 0.5},
192
+ {"gamma_init": 2.0, "gamma_max": 1.5},
193
+ ],
194
+ )
195
+ def test_invalid_hyperparameters_raise(kwargs):
196
+ with pytest.raises(ValueError):
197
+ aura_optax.scale_by_aura(**kwargs)