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.
- aura_optax-0.1.0/.gitignore +15 -0
- aura_optax-0.1.0/LICENSE +21 -0
- aura_optax-0.1.0/PKG-INFO +127 -0
- aura_optax-0.1.0/README.md +104 -0
- aura_optax-0.1.0/aura_optax/__init__.py +8 -0
- aura_optax-0.1.0/aura_optax/_alias.py +105 -0
- aura_optax-0.1.0/aura_optax/_aura.py +110 -0
- aura_optax-0.1.0/aura_optax/_muon.py +53 -0
- aura_optax-0.1.0/pyproject.toml +44 -0
- aura_optax-0.1.0/tests/test_aura.py +197 -0
aura_optax-0.1.0/LICENSE
ADDED
|
@@ -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)
|