echomuon 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.
- echomuon-0.1.0/LICENSE +21 -0
- echomuon-0.1.0/PKG-INFO +125 -0
- echomuon-0.1.0/README.md +105 -0
- echomuon-0.1.0/pyproject.toml +28 -0
- echomuon-0.1.0/setup.cfg +4 -0
- echomuon-0.1.0/src/echomuon/__init__.py +7 -0
- echomuon-0.1.0/src/echomuon/optimizer.py +228 -0
- echomuon-0.1.0/src/echomuon.egg-info/PKG-INFO +125 -0
- echomuon-0.1.0/src/echomuon.egg-info/SOURCES.txt +11 -0
- echomuon-0.1.0/src/echomuon.egg-info/dependency_links.txt +1 -0
- echomuon-0.1.0/src/echomuon.egg-info/requires.txt +1 -0
- echomuon-0.1.0/src/echomuon.egg-info/top_level.txt +1 -0
- echomuon-0.1.0/tests/test_echomuon.py +133 -0
echomuon-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stamatis Mastromichalakis
|
|
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.
|
echomuon-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: echomuon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: EchoMuon: Muon with a per-direction temporal trust gate and a memorization-gap controller. Better than scheduled Muon wherever data are imperfect.
|
|
5
|
+
Author-email: Stamatis Mastromichalakis <stamatis@tmnetworks.gr>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MStamatis/echomuon
|
|
8
|
+
Project-URL: Paper, https://arxiv.org/abs/ARXIV-PLACEHOLDER
|
|
9
|
+
Keywords: optimizer,muon,deep-learning,pytorch,label-noise,orthogonalization
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: torch>=2.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# EchoMuon
|
|
22
|
+
|
|
23
|
+
**Muon with a per-direction temporal trust gate and a memorization-gap controller.
|
|
24
|
+
Better than scheduled Muon wherever data are imperfect; ties it everywhere else.**
|
|
25
|
+
|
|
26
|
+
[Muon](https://kellerjordan.github.io/posts/muon/) orthogonalizes the momentum of
|
|
27
|
+
2-D hidden weight matrices, giving every singular direction of the update exactly
|
|
28
|
+
equal trust. EchoMuon prices that trust by each direction's *echo* — its support in
|
|
29
|
+
a second, slower momentum buffer (β=0.99 next to Muon's 0.95). Persistent signal
|
|
30
|
+
appears in both buffers; noise flickers in the fast one and leaves no trace in the
|
|
31
|
+
slow one. The gate:
|
|
32
|
+
|
|
33
|
+
- scores each singular direction by cross-timescale **agreement**
|
|
34
|
+
`c_i = uᵢᵀ M₂M₁ᵀ uᵢ / σᵢ²` — *not* by magnitude, so a large direction sustained by
|
|
35
|
+
a few noisy batches is damped while a small persistent one is trusted;
|
|
36
|
+
- is **median-normalized per layer** (mean ≈ 1): trust is reallocated across
|
|
37
|
+
directions at constant total step, so the gate cannot act as a disguised
|
|
38
|
+
learning-rate schedule;
|
|
39
|
+
- is engaged in proportion to a **measured memorization gap** λ ∈ [0, 1]: the loss
|
|
40
|
+
on fresh batches minus the loss on batches seen a few hundred steps ago, under
|
|
41
|
+
the same current weights. At λ=0 EchoMuon *is* Muon, structurally — never worse
|
|
42
|
+
by construction on clean data.
|
|
43
|
+
|
|
44
|
+
Headline results (paired seeds, schedule parity, per-arm lr sweeps): **+0.9 to
|
|
45
|
+
+1.8pp** over scheduled Muon on six vision cells (CIFAR-10/100, Tiny ImageNet,
|
|
46
|
+
clean and 20% label noise), **−0.031 nats (t=−6.3)** on a LLaMA-style 162M
|
|
47
|
+
transformer on FineWeb-Edu, Muon-tier quality in 80–85% of Muon's steps in every
|
|
48
|
+
seed, ~13% step overhead at 162M with the fast profile. See the paper for the
|
|
49
|
+
boundaries (byte-level LMs, SSMs, strong augmentation recipes) — they are reported,
|
|
50
|
+
measured, and part of the result.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install echomuon
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
EchoMuon handles only the 2-D hidden matrices, exactly like Muon; route
|
|
61
|
+
embeddings, heads, gains and biases to AdamW:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import torch
|
|
65
|
+
from echomuon import EchoMuon, MemorizationGapController
|
|
66
|
+
|
|
67
|
+
hidden = [p for n, p in model.named_parameters()
|
|
68
|
+
if p.ndim == 2 and "embed" not in n and "lm_head" not in n]
|
|
69
|
+
others = [p for n, p in model.named_parameters()
|
|
70
|
+
if not any(p is h for h in hidden)]
|
|
71
|
+
|
|
72
|
+
opt = EchoMuon(hidden, lr=0.02) # sweep lr as you would for Muon
|
|
73
|
+
aux = torch.optim.AdamW(others, lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Training loop with the memorization-gap controller (optional but recommended —
|
|
77
|
+
without it, set `gate_lambda` yourself; `gate_lambda=0` is plain Muon):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import collections
|
|
81
|
+
|
|
82
|
+
ctl = MemorizationGapController(opt) # probes every 200 steps by default
|
|
83
|
+
ring = collections.deque(maxlen=8) # the last 8 training batches
|
|
84
|
+
|
|
85
|
+
for step, batch in enumerate(loader):
|
|
86
|
+
loss = model(**batch).loss
|
|
87
|
+
opt.zero_grad(); aux.zero_grad()
|
|
88
|
+
loss.backward()
|
|
89
|
+
opt.step(); aux.step()
|
|
90
|
+
ring.append(batch)
|
|
91
|
+
|
|
92
|
+
if ctl.due(step):
|
|
93
|
+
with torch.no_grad():
|
|
94
|
+
reseen = mean_loss(model, list(ring)[:4]) # oldest ~400-800 steps ago
|
|
95
|
+
fresh = mean_loss(model, take_fresh(4)) # 4 held-back fresh batches
|
|
96
|
+
ctl.update(fresh_loss=fresh, reseen_loss=reseen)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
All constants (slow β=0.99, floor 0.1, median reference, the 2% normalizer, the
|
|
100
|
+
0.7 EMA) were fixed once and used unchanged in every experiment of the paper —
|
|
101
|
+
the only knob you tune is Muon's own learning rate.
|
|
102
|
+
|
|
103
|
+
## When to use it
|
|
104
|
+
|
|
105
|
+
| Your setting | Recommendation |
|
|
106
|
+
|---|---|
|
|
107
|
+
| Web-scale corpora, label noise, ambiguous labels, light augmentation | **EchoMuon** — this is where the margins live |
|
|
108
|
+
| Clean data / strong augmentation (RandAugment + mixup) | Tie with Muon; λ backs the gate off automatically |
|
|
109
|
+
| Byte-level LMs at scale | Scheduled Muon (measured boundary; EchoMuon concedes ≤0.5%) |
|
|
110
|
+
| Mamba-style SSMs | AdamW beats the whole Muon family there (measured boundary) |
|
|
111
|
+
|
|
112
|
+
## Citation
|
|
113
|
+
|
|
114
|
+
```bibtex
|
|
115
|
+
@article{mastromichalakis2026echomuon,
|
|
116
|
+
title = {EchoMuon: Better Than Scheduled Muon Wherever Data Are Imperfect},
|
|
117
|
+
author = {Mastromichalakis, Stamatis},
|
|
118
|
+
year = {2026},
|
|
119
|
+
note = {arXiv preprint}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
echomuon-0.1.0/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# EchoMuon
|
|
2
|
+
|
|
3
|
+
**Muon with a per-direction temporal trust gate and a memorization-gap controller.
|
|
4
|
+
Better than scheduled Muon wherever data are imperfect; ties it everywhere else.**
|
|
5
|
+
|
|
6
|
+
[Muon](https://kellerjordan.github.io/posts/muon/) orthogonalizes the momentum of
|
|
7
|
+
2-D hidden weight matrices, giving every singular direction of the update exactly
|
|
8
|
+
equal trust. EchoMuon prices that trust by each direction's *echo* — its support in
|
|
9
|
+
a second, slower momentum buffer (β=0.99 next to Muon's 0.95). Persistent signal
|
|
10
|
+
appears in both buffers; noise flickers in the fast one and leaves no trace in the
|
|
11
|
+
slow one. The gate:
|
|
12
|
+
|
|
13
|
+
- scores each singular direction by cross-timescale **agreement**
|
|
14
|
+
`c_i = uᵢᵀ M₂M₁ᵀ uᵢ / σᵢ²` — *not* by magnitude, so a large direction sustained by
|
|
15
|
+
a few noisy batches is damped while a small persistent one is trusted;
|
|
16
|
+
- is **median-normalized per layer** (mean ≈ 1): trust is reallocated across
|
|
17
|
+
directions at constant total step, so the gate cannot act as a disguised
|
|
18
|
+
learning-rate schedule;
|
|
19
|
+
- is engaged in proportion to a **measured memorization gap** λ ∈ [0, 1]: the loss
|
|
20
|
+
on fresh batches minus the loss on batches seen a few hundred steps ago, under
|
|
21
|
+
the same current weights. At λ=0 EchoMuon *is* Muon, structurally — never worse
|
|
22
|
+
by construction on clean data.
|
|
23
|
+
|
|
24
|
+
Headline results (paired seeds, schedule parity, per-arm lr sweeps): **+0.9 to
|
|
25
|
+
+1.8pp** over scheduled Muon on six vision cells (CIFAR-10/100, Tiny ImageNet,
|
|
26
|
+
clean and 20% label noise), **−0.031 nats (t=−6.3)** on a LLaMA-style 162M
|
|
27
|
+
transformer on FineWeb-Edu, Muon-tier quality in 80–85% of Muon's steps in every
|
|
28
|
+
seed, ~13% step overhead at 162M with the fast profile. See the paper for the
|
|
29
|
+
boundaries (byte-level LMs, SSMs, strong augmentation recipes) — they are reported,
|
|
30
|
+
measured, and part of the result.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install echomuon
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
EchoMuon handles only the 2-D hidden matrices, exactly like Muon; route
|
|
41
|
+
embeddings, heads, gains and biases to AdamW:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import torch
|
|
45
|
+
from echomuon import EchoMuon, MemorizationGapController
|
|
46
|
+
|
|
47
|
+
hidden = [p for n, p in model.named_parameters()
|
|
48
|
+
if p.ndim == 2 and "embed" not in n and "lm_head" not in n]
|
|
49
|
+
others = [p for n, p in model.named_parameters()
|
|
50
|
+
if not any(p is h for h in hidden)]
|
|
51
|
+
|
|
52
|
+
opt = EchoMuon(hidden, lr=0.02) # sweep lr as you would for Muon
|
|
53
|
+
aux = torch.optim.AdamW(others, lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Training loop with the memorization-gap controller (optional but recommended —
|
|
57
|
+
without it, set `gate_lambda` yourself; `gate_lambda=0` is plain Muon):
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import collections
|
|
61
|
+
|
|
62
|
+
ctl = MemorizationGapController(opt) # probes every 200 steps by default
|
|
63
|
+
ring = collections.deque(maxlen=8) # the last 8 training batches
|
|
64
|
+
|
|
65
|
+
for step, batch in enumerate(loader):
|
|
66
|
+
loss = model(**batch).loss
|
|
67
|
+
opt.zero_grad(); aux.zero_grad()
|
|
68
|
+
loss.backward()
|
|
69
|
+
opt.step(); aux.step()
|
|
70
|
+
ring.append(batch)
|
|
71
|
+
|
|
72
|
+
if ctl.due(step):
|
|
73
|
+
with torch.no_grad():
|
|
74
|
+
reseen = mean_loss(model, list(ring)[:4]) # oldest ~400-800 steps ago
|
|
75
|
+
fresh = mean_loss(model, take_fresh(4)) # 4 held-back fresh batches
|
|
76
|
+
ctl.update(fresh_loss=fresh, reseen_loss=reseen)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
All constants (slow β=0.99, floor 0.1, median reference, the 2% normalizer, the
|
|
80
|
+
0.7 EMA) were fixed once and used unchanged in every experiment of the paper —
|
|
81
|
+
the only knob you tune is Muon's own learning rate.
|
|
82
|
+
|
|
83
|
+
## When to use it
|
|
84
|
+
|
|
85
|
+
| Your setting | Recommendation |
|
|
86
|
+
|---|---|
|
|
87
|
+
| Web-scale corpora, label noise, ambiguous labels, light augmentation | **EchoMuon** — this is where the margins live |
|
|
88
|
+
| Clean data / strong augmentation (RandAugment + mixup) | Tie with Muon; λ backs the gate off automatically |
|
|
89
|
+
| Byte-level LMs at scale | Scheduled Muon (measured boundary; EchoMuon concedes ≤0.5%) |
|
|
90
|
+
| Mamba-style SSMs | AdamW beats the whole Muon family there (measured boundary) |
|
|
91
|
+
|
|
92
|
+
## Citation
|
|
93
|
+
|
|
94
|
+
```bibtex
|
|
95
|
+
@article{mastromichalakis2026echomuon,
|
|
96
|
+
title = {EchoMuon: Better Than Scheduled Muon Wherever Data Are Imperfect},
|
|
97
|
+
author = {Mastromichalakis, Stamatis},
|
|
98
|
+
year = {2026},
|
|
99
|
+
note = {arXiv preprint}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "echomuon"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "EchoMuon: Muon with a per-direction temporal trust gate and a memorization-gap controller. Better than scheduled Muon wherever data are imperfect."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Stamatis Mastromichalakis", email = "stamatis@tmnetworks.gr" }]
|
|
13
|
+
keywords = ["optimizer", "muon", "deep-learning", "pytorch", "label-noise", "orthogonalization"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["torch>=2.0"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/MStamatis/echomuon"
|
|
25
|
+
Paper = "https://arxiv.org/abs/ARXIV-PLACEHOLDER"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
echomuon-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""EchoMuon: Muon with a per-direction temporal trust gate and a
|
|
2
|
+
memorization-gap controller. Better than scheduled Muon wherever data are
|
|
3
|
+
imperfect; ties it everywhere else."""
|
|
4
|
+
from .optimizer import EchoMuon, MemorizationGapController, newton_schulz5
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = ["EchoMuon", "MemorizationGapController", "newton_schulz5", "__version__"]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""EchoMuon: Muon with a per-direction temporal trust gate and an optional
|
|
2
|
+
memorization-gap controller.
|
|
3
|
+
|
|
4
|
+
Muon (Jordan et al., 2024) orthogonalizes the momentum of 2-D hidden weight
|
|
5
|
+
matrices, giving every singular direction of the update equal trust. EchoMuon
|
|
6
|
+
prices that trust by each direction's *echo*: its support in a second, slower
|
|
7
|
+
momentum buffer. Directions whose fast/slow buffers agree keep their step;
|
|
8
|
+
directions with no echo are damped. Gates are median-normalized per layer
|
|
9
|
+
(mean ~1), so trust is reallocated across directions at constant total step --
|
|
10
|
+
the gate cannot act as a disguised learning-rate schedule -- and a floor keeps
|
|
11
|
+
every direction alive. A scalar ``gate_lambda`` in [0, 1] interpolates the gate
|
|
12
|
+
between OFF (exactly plain Muon) and fully ON; the MemorizationGapController
|
|
13
|
+
sets it from a measured old-vs-fresh loss gap.
|
|
14
|
+
|
|
15
|
+
Reference: S. Mastromichalakis, "EchoMuon: Better Than Scheduled Muon Wherever
|
|
16
|
+
Data Are Imperfect", 2026.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
|
|
22
|
+
import torch
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["EchoMuon", "MemorizationGapController", "newton_schulz5"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def newton_schulz5(G: torch.Tensor, steps: int = 5, eps: float = 1e-7) -> torch.Tensor:
|
|
29
|
+
"""Quintic Newton-Schulz orthogonalization (Keller Jordan's coefficients)."""
|
|
30
|
+
a, b, c = 3.4445, -4.7750, 2.0315
|
|
31
|
+
X = G.to(torch.bfloat16) if G.is_cuda else G.to(torch.float32)
|
|
32
|
+
transposed = X.size(0) > X.size(1)
|
|
33
|
+
if transposed:
|
|
34
|
+
X = X.T
|
|
35
|
+
X = X / (X.norm() + eps)
|
|
36
|
+
for _ in range(steps):
|
|
37
|
+
A = X @ X.T
|
|
38
|
+
B = b * A + c * (A @ A)
|
|
39
|
+
X = a * X + B @ X
|
|
40
|
+
if transposed:
|
|
41
|
+
X = X.T
|
|
42
|
+
return X.to(G.dtype)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EchoMuon(torch.optim.Optimizer):
|
|
46
|
+
"""EchoMuon optimizer for 2-D hidden weight matrices.
|
|
47
|
+
|
|
48
|
+
Like Muon, this optimizer is meant ONLY for the hidden matrix parameters of
|
|
49
|
+
a network (attention/MLP weights). Embeddings, output heads, gains, and
|
|
50
|
+
biases should be handled by a separate AdamW -- exactly as with Muon.
|
|
51
|
+
|
|
52
|
+
Arguments:
|
|
53
|
+
params: 2-D parameters to optimize (an iterable of tensors or dicts).
|
|
54
|
+
lr: learning rate (Muon convention; sweep it as you would for Muon).
|
|
55
|
+
momentum: fast EMA-sum decay (Muon's own buffer), default 0.95.
|
|
56
|
+
slow_beta: slow buffer decay, default 0.99. The gate scores each
|
|
57
|
+
singular direction of the fast buffer by its normalized support in
|
|
58
|
+
this slower buffer.
|
|
59
|
+
nesterov: use the Nesterov form of the fast buffer (default True).
|
|
60
|
+
weight_decay: decoupled weight decay (default 0).
|
|
61
|
+
ns_steps: Newton-Schulz iterations (default 5).
|
|
62
|
+
gate_every: refresh the gate basis every this many steps (default 100,
|
|
63
|
+
the paper's "fast profile"; 25 is the standard profile -- same
|
|
64
|
+
quality within noise, ~2x the gate overhead).
|
|
65
|
+
gate_floor: minimum gate value so no direction is fully silenced
|
|
66
|
+
(default 0.1).
|
|
67
|
+
gate_lambda: gate strength in [0, 1]. 0 = exactly plain Muon; 1 = full
|
|
68
|
+
gate. Set it directly, or let MemorizationGapController drive it
|
|
69
|
+
from a measured memorization gap.
|
|
70
|
+
|
|
71
|
+
Example::
|
|
72
|
+
|
|
73
|
+
hidden = [p for n, p in model.named_parameters()
|
|
74
|
+
if p.ndim == 2 and "embed" not in n and "head" not in n]
|
|
75
|
+
others = [p for n, p in model.named_parameters() if p not in set(hidden)]
|
|
76
|
+
opt = EchoMuon(hidden, lr=0.02)
|
|
77
|
+
aux = torch.optim.AdamW(others, lr=3e-4, weight_decay=0.1)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, params, lr: float = 0.02, momentum: float = 0.95,
|
|
81
|
+
slow_beta: float = 0.99, nesterov: bool = True,
|
|
82
|
+
weight_decay: float = 0.0, ns_steps: int = 5,
|
|
83
|
+
gate_every: int = 100, gate_floor: float = 0.1,
|
|
84
|
+
gate_lambda: float = 1.0):
|
|
85
|
+
if not 0.0 <= gate_lambda <= 1.0:
|
|
86
|
+
raise ValueError("gate_lambda must be in [0, 1]")
|
|
87
|
+
defaults = dict(lr=lr, momentum=momentum, slow_beta=slow_beta,
|
|
88
|
+
nesterov=nesterov, weight_decay=weight_decay,
|
|
89
|
+
ns_steps=ns_steps, gate_every=max(1, gate_every),
|
|
90
|
+
gate_floor=gate_floor)
|
|
91
|
+
super().__init__(params, defaults)
|
|
92
|
+
for group in self.param_groups:
|
|
93
|
+
for p in group["params"]:
|
|
94
|
+
if p.ndim != 2:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"EchoMuon only accepts 2-D parameters; route "
|
|
97
|
+
f"{tuple(p.shape)} tensors to AdamW instead.")
|
|
98
|
+
self.gate_lambda = float(gate_lambda)
|
|
99
|
+
self._t = 0
|
|
100
|
+
|
|
101
|
+
@torch.no_grad()
|
|
102
|
+
def _refresh_gate(self, p, group):
|
|
103
|
+
"""Recompute the temporal-consistency gate for one parameter.
|
|
104
|
+
|
|
105
|
+
For each singular direction u_i of the fast buffer M1 (via the Gram
|
|
106
|
+
eigendecomposition on the small side), consistency
|
|
107
|
+
c_i = (u_i^T M2 M1^T u_i) / sigma_i^2 is the slow buffer's relative
|
|
108
|
+
support along that direction. Gates are median-normalized per layer so
|
|
109
|
+
they redistribute (mean ~1) rather than rescale.
|
|
110
|
+
"""
|
|
111
|
+
st = self.state[p]
|
|
112
|
+
M1 = st["buf"].float()
|
|
113
|
+
M2 = st["buf2"].float()
|
|
114
|
+
if M1.size(0) > M1.size(1):
|
|
115
|
+
M1, M2 = M1.T, M2.T
|
|
116
|
+
G = M1 @ M1.T
|
|
117
|
+
evals, U = torch.linalg.eigh(G)
|
|
118
|
+
evals = evals.clamp_min(1e-12)
|
|
119
|
+
C = U.T @ (M2 @ M1.T) @ U
|
|
120
|
+
c = C.diagonal() / evals
|
|
121
|
+
med = torch.quantile(c, 0.5).clamp_min(1e-12)
|
|
122
|
+
g = (c / med).clamp(group["gate_floor"], 1.0)
|
|
123
|
+
st["gate_U"], st["gate_g"] = U, g
|
|
124
|
+
|
|
125
|
+
@torch.no_grad()
|
|
126
|
+
def step(self, closure=None):
|
|
127
|
+
loss = None
|
|
128
|
+
if closure is not None:
|
|
129
|
+
with torch.enable_grad():
|
|
130
|
+
loss = closure()
|
|
131
|
+
self._t += 1
|
|
132
|
+
refresh = (self._t % self.param_groups[0]["gate_every"] == 0)
|
|
133
|
+
for group in self.param_groups:
|
|
134
|
+
beta = group["momentum"]
|
|
135
|
+
for p in group["params"]:
|
|
136
|
+
if p.grad is None:
|
|
137
|
+
continue
|
|
138
|
+
st = self.state[p]
|
|
139
|
+
if "buf" not in st:
|
|
140
|
+
st["buf"] = torch.zeros_like(p)
|
|
141
|
+
st["buf2"] = torch.zeros_like(p)
|
|
142
|
+
buf, buf2 = st["buf"], st["buf2"]
|
|
143
|
+
buf.mul_(beta).add_(p.grad)
|
|
144
|
+
buf2.mul_(group["slow_beta"]).add_(p.grad)
|
|
145
|
+
if refresh:
|
|
146
|
+
self._refresh_gate(p, group)
|
|
147
|
+
u = p.grad.add(buf, alpha=beta) if group["nesterov"] else buf
|
|
148
|
+
d = newton_schulz5(u, group["ns_steps"])
|
|
149
|
+
if "gate_U" in st and self.gate_lambda > 0.0:
|
|
150
|
+
U, g = st["gate_U"], st["gate_g"]
|
|
151
|
+
g_eff = 1.0 - self.gate_lambda * (1.0 - g)
|
|
152
|
+
transposed = d.size(0) > d.size(1)
|
|
153
|
+
O = (d.T if transposed else d).float()
|
|
154
|
+
O = O - U @ ((1.0 - g_eff).unsqueeze(1) * (U.T @ O))
|
|
155
|
+
d = (O.T if transposed else O).to(d.dtype)
|
|
156
|
+
d = d * math.sqrt(max(1.0, p.size(0) / p.size(1)))
|
|
157
|
+
if group["weight_decay"]:
|
|
158
|
+
p.mul_(1 - group["lr"] * group["weight_decay"])
|
|
159
|
+
p.add_(d, alpha=-group["lr"])
|
|
160
|
+
return loss
|
|
161
|
+
|
|
162
|
+
def state_dict(self):
|
|
163
|
+
sd = super().state_dict()
|
|
164
|
+
sd["echomuon_t"] = self._t
|
|
165
|
+
sd["echomuon_lambda"] = self.gate_lambda
|
|
166
|
+
return sd
|
|
167
|
+
|
|
168
|
+
def load_state_dict(self, state_dict):
|
|
169
|
+
self._t = state_dict.pop("echomuon_t", 0)
|
|
170
|
+
self.gate_lambda = state_dict.pop("echomuon_lambda", self.gate_lambda)
|
|
171
|
+
super().load_state_dict(state_dict)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class MemorizationGapController:
|
|
175
|
+
"""Drives ``EchoMuon.gate_lambda`` from a measured memorization gap.
|
|
176
|
+
|
|
177
|
+
The signal: re-evaluate, under the CURRENT weights, batches the model
|
|
178
|
+
trained on a few hundred steps ago, alongside fresh batches. The gap
|
|
179
|
+
(fresh loss - re-seen loss) is memorization, measured on the model itself;
|
|
180
|
+
lambda = clip(gap / (target_frac * fresh_loss), 0, 1), EMA-smoothed. On
|
|
181
|
+
clean data the gap vanishes and EchoMuon relaxes to exactly plain Muon.
|
|
182
|
+
|
|
183
|
+
The controller does not run the forward passes itself -- your training
|
|
184
|
+
loop supplies the two loss values (this keeps the package framework-free).
|
|
185
|
+
|
|
186
|
+
Example::
|
|
187
|
+
|
|
188
|
+
ctl = MemorizationGapController(opt) # opt is an EchoMuon
|
|
189
|
+
ring = collections.deque(maxlen=8) # your recent batches
|
|
190
|
+
for step, batch in enumerate(loader):
|
|
191
|
+
train_step(batch); ring.append(batch)
|
|
192
|
+
if ctl.due(step):
|
|
193
|
+
with torch.no_grad():
|
|
194
|
+
reseen = mean_loss(model, list(ring)[:4]) # oldest 4
|
|
195
|
+
fresh = mean_loss(model, next_fresh_batches(4))
|
|
196
|
+
ctl.update(fresh_loss=fresh, reseen_loss=reseen)
|
|
197
|
+
|
|
198
|
+
Arguments:
|
|
199
|
+
optimizer: the EchoMuon instance whose gate_lambda is controlled.
|
|
200
|
+
probe_every: probe cadence in steps (default 200, the fast profile;
|
|
201
|
+
100 is the standard profile).
|
|
202
|
+
target_frac: gap normalizer as a fraction of the fresh loss
|
|
203
|
+
(default 0.02 -- fixed a priori in the paper, never retuned).
|
|
204
|
+
ema: smoothing of lambda across probes (default 0.7).
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(self, optimizer: EchoMuon, probe_every: int = 200,
|
|
208
|
+
target_frac: float = 0.02, ema: float = 0.7):
|
|
209
|
+
self.opt = optimizer
|
|
210
|
+
self.probe_every = max(1, probe_every)
|
|
211
|
+
self.target_frac = target_frac
|
|
212
|
+
self.ema = ema
|
|
213
|
+
self._lam = optimizer.gate_lambda
|
|
214
|
+
|
|
215
|
+
def due(self, step: int) -> bool:
|
|
216
|
+
return step > 0 and step % self.probe_every == 0
|
|
217
|
+
|
|
218
|
+
def update(self, fresh_loss: float, reseen_loss: float) -> float:
|
|
219
|
+
gap = float(fresh_loss) - float(reseen_loss)
|
|
220
|
+
target = self.target_frac * max(float(fresh_loss), 1e-12)
|
|
221
|
+
lam_now = min(max(gap / target, 0.0), 1.0)
|
|
222
|
+
self._lam = self.ema * self._lam + (1.0 - self.ema) * lam_now
|
|
223
|
+
self.opt.gate_lambda = self._lam
|
|
224
|
+
return self._lam
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def gate_lambda(self) -> float:
|
|
228
|
+
return self._lam
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: echomuon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: EchoMuon: Muon with a per-direction temporal trust gate and a memorization-gap controller. Better than scheduled Muon wherever data are imperfect.
|
|
5
|
+
Author-email: Stamatis Mastromichalakis <stamatis@tmnetworks.gr>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MStamatis/echomuon
|
|
8
|
+
Project-URL: Paper, https://arxiv.org/abs/ARXIV-PLACEHOLDER
|
|
9
|
+
Keywords: optimizer,muon,deep-learning,pytorch,label-noise,orthogonalization
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: torch>=2.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# EchoMuon
|
|
22
|
+
|
|
23
|
+
**Muon with a per-direction temporal trust gate and a memorization-gap controller.
|
|
24
|
+
Better than scheduled Muon wherever data are imperfect; ties it everywhere else.**
|
|
25
|
+
|
|
26
|
+
[Muon](https://kellerjordan.github.io/posts/muon/) orthogonalizes the momentum of
|
|
27
|
+
2-D hidden weight matrices, giving every singular direction of the update exactly
|
|
28
|
+
equal trust. EchoMuon prices that trust by each direction's *echo* — its support in
|
|
29
|
+
a second, slower momentum buffer (β=0.99 next to Muon's 0.95). Persistent signal
|
|
30
|
+
appears in both buffers; noise flickers in the fast one and leaves no trace in the
|
|
31
|
+
slow one. The gate:
|
|
32
|
+
|
|
33
|
+
- scores each singular direction by cross-timescale **agreement**
|
|
34
|
+
`c_i = uᵢᵀ M₂M₁ᵀ uᵢ / σᵢ²` — *not* by magnitude, so a large direction sustained by
|
|
35
|
+
a few noisy batches is damped while a small persistent one is trusted;
|
|
36
|
+
- is **median-normalized per layer** (mean ≈ 1): trust is reallocated across
|
|
37
|
+
directions at constant total step, so the gate cannot act as a disguised
|
|
38
|
+
learning-rate schedule;
|
|
39
|
+
- is engaged in proportion to a **measured memorization gap** λ ∈ [0, 1]: the loss
|
|
40
|
+
on fresh batches minus the loss on batches seen a few hundred steps ago, under
|
|
41
|
+
the same current weights. At λ=0 EchoMuon *is* Muon, structurally — never worse
|
|
42
|
+
by construction on clean data.
|
|
43
|
+
|
|
44
|
+
Headline results (paired seeds, schedule parity, per-arm lr sweeps): **+0.9 to
|
|
45
|
+
+1.8pp** over scheduled Muon on six vision cells (CIFAR-10/100, Tiny ImageNet,
|
|
46
|
+
clean and 20% label noise), **−0.031 nats (t=−6.3)** on a LLaMA-style 162M
|
|
47
|
+
transformer on FineWeb-Edu, Muon-tier quality in 80–85% of Muon's steps in every
|
|
48
|
+
seed, ~13% step overhead at 162M with the fast profile. See the paper for the
|
|
49
|
+
boundaries (byte-level LMs, SSMs, strong augmentation recipes) — they are reported,
|
|
50
|
+
measured, and part of the result.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install echomuon
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
EchoMuon handles only the 2-D hidden matrices, exactly like Muon; route
|
|
61
|
+
embeddings, heads, gains and biases to AdamW:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import torch
|
|
65
|
+
from echomuon import EchoMuon, MemorizationGapController
|
|
66
|
+
|
|
67
|
+
hidden = [p for n, p in model.named_parameters()
|
|
68
|
+
if p.ndim == 2 and "embed" not in n and "lm_head" not in n]
|
|
69
|
+
others = [p for n, p in model.named_parameters()
|
|
70
|
+
if not any(p is h for h in hidden)]
|
|
71
|
+
|
|
72
|
+
opt = EchoMuon(hidden, lr=0.02) # sweep lr as you would for Muon
|
|
73
|
+
aux = torch.optim.AdamW(others, lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Training loop with the memorization-gap controller (optional but recommended —
|
|
77
|
+
without it, set `gate_lambda` yourself; `gate_lambda=0` is plain Muon):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import collections
|
|
81
|
+
|
|
82
|
+
ctl = MemorizationGapController(opt) # probes every 200 steps by default
|
|
83
|
+
ring = collections.deque(maxlen=8) # the last 8 training batches
|
|
84
|
+
|
|
85
|
+
for step, batch in enumerate(loader):
|
|
86
|
+
loss = model(**batch).loss
|
|
87
|
+
opt.zero_grad(); aux.zero_grad()
|
|
88
|
+
loss.backward()
|
|
89
|
+
opt.step(); aux.step()
|
|
90
|
+
ring.append(batch)
|
|
91
|
+
|
|
92
|
+
if ctl.due(step):
|
|
93
|
+
with torch.no_grad():
|
|
94
|
+
reseen = mean_loss(model, list(ring)[:4]) # oldest ~400-800 steps ago
|
|
95
|
+
fresh = mean_loss(model, take_fresh(4)) # 4 held-back fresh batches
|
|
96
|
+
ctl.update(fresh_loss=fresh, reseen_loss=reseen)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
All constants (slow β=0.99, floor 0.1, median reference, the 2% normalizer, the
|
|
100
|
+
0.7 EMA) were fixed once and used unchanged in every experiment of the paper —
|
|
101
|
+
the only knob you tune is Muon's own learning rate.
|
|
102
|
+
|
|
103
|
+
## When to use it
|
|
104
|
+
|
|
105
|
+
| Your setting | Recommendation |
|
|
106
|
+
|---|---|
|
|
107
|
+
| Web-scale corpora, label noise, ambiguous labels, light augmentation | **EchoMuon** — this is where the margins live |
|
|
108
|
+
| Clean data / strong augmentation (RandAugment + mixup) | Tie with Muon; λ backs the gate off automatically |
|
|
109
|
+
| Byte-level LMs at scale | Scheduled Muon (measured boundary; EchoMuon concedes ≤0.5%) |
|
|
110
|
+
| Mamba-style SSMs | AdamW beats the whole Muon family there (measured boundary) |
|
|
111
|
+
|
|
112
|
+
## Citation
|
|
113
|
+
|
|
114
|
+
```bibtex
|
|
115
|
+
@article{mastromichalakis2026echomuon,
|
|
116
|
+
title = {EchoMuon: Better Than Scheduled Muon Wherever Data Are Imperfect},
|
|
117
|
+
author = {Mastromichalakis, Stamatis},
|
|
118
|
+
year = {2026},
|
|
119
|
+
note = {arXiv preprint}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/echomuon/__init__.py
|
|
5
|
+
src/echomuon/optimizer.py
|
|
6
|
+
src/echomuon.egg-info/PKG-INFO
|
|
7
|
+
src/echomuon.egg-info/SOURCES.txt
|
|
8
|
+
src/echomuon.egg-info/dependency_links.txt
|
|
9
|
+
src/echomuon.egg-info/requires.txt
|
|
10
|
+
src/echomuon.egg-info/top_level.txt
|
|
11
|
+
tests/test_echomuon.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
torch>=2.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
echomuon
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""CPU tests for the echomuon package. Run: python tests/test_echomuon.py (or pytest)."""
|
|
2
|
+
import copy
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
from echomuon import EchoMuon, MemorizationGapController
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_model(seed=0):
|
|
11
|
+
torch.manual_seed(seed)
|
|
12
|
+
return torch.nn.Sequential(
|
|
13
|
+
torch.nn.Linear(32, 64, bias=False),
|
|
14
|
+
torch.nn.ReLU(),
|
|
15
|
+
torch.nn.Linear(64, 10, bias=False),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def data(seed=1, n=60):
|
|
20
|
+
g = torch.Generator().manual_seed(seed)
|
|
21
|
+
for _ in range(n):
|
|
22
|
+
x = torch.randn(16, 32, generator=g)
|
|
23
|
+
y = torch.randint(0, 10, (16,), generator=g)
|
|
24
|
+
yield x, y
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(model, opt, n=60, lam=None):
|
|
28
|
+
losses = []
|
|
29
|
+
for x, y in data(n=n):
|
|
30
|
+
loss = torch.nn.functional.cross_entropy(model(x), y)
|
|
31
|
+
opt.zero_grad()
|
|
32
|
+
loss.backward()
|
|
33
|
+
if lam is not None:
|
|
34
|
+
opt.gate_lambda = lam
|
|
35
|
+
opt.step()
|
|
36
|
+
losses.append(float(loss.detach()))
|
|
37
|
+
return losses
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def flat(model):
|
|
41
|
+
return torch.cat([p.detach().flatten() for p in model.parameters()])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_trains():
|
|
45
|
+
# a memorizable task: cycle over the same 4 fixed batches
|
|
46
|
+
m = make_model()
|
|
47
|
+
opt = EchoMuon(m.parameters(), lr=0.02, gate_every=10)
|
|
48
|
+
batches = list(data(n=4))
|
|
49
|
+
losses = []
|
|
50
|
+
for i in range(80):
|
|
51
|
+
x, y = batches[i % 4]
|
|
52
|
+
loss = torch.nn.functional.cross_entropy(m(x), y)
|
|
53
|
+
opt.zero_grad(); loss.backward(); opt.step()
|
|
54
|
+
losses.append(float(loss.detach()))
|
|
55
|
+
assert all(torch.isfinite(torch.tensor(losses)))
|
|
56
|
+
assert sum(losses[-8:]) < sum(losses[:8]), "loss did not decrease"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_lambda_zero_is_plain_muon():
|
|
60
|
+
# gate_lambda=0 must be bit-identical to never refreshing the gate at all
|
|
61
|
+
m0 = make_model()
|
|
62
|
+
o0 = EchoMuon(m0.parameters(), lr=0.02, gate_every=10, gate_lambda=0.0)
|
|
63
|
+
m1 = make_model()
|
|
64
|
+
o1 = EchoMuon(m1.parameters(), lr=0.02, gate_every=10 ** 9) # gate never built
|
|
65
|
+
run(m0, o0, n=30)
|
|
66
|
+
run(m1, o1, n=30)
|
|
67
|
+
assert torch.allclose(flat(m0), flat(m1), atol=0), "lambda=0 != plain Muon"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_gate_changes_trajectory():
|
|
71
|
+
m0 = make_model()
|
|
72
|
+
o0 = EchoMuon(m0.parameters(), lr=0.02, gate_every=10, gate_lambda=1.0)
|
|
73
|
+
m1 = make_model()
|
|
74
|
+
o1 = EchoMuon(m1.parameters(), lr=0.02, gate_every=10, gate_lambda=0.0)
|
|
75
|
+
run(m0, o0, n=30)
|
|
76
|
+
run(m1, o1, n=30)
|
|
77
|
+
assert not torch.allclose(flat(m0), flat(m1)), "gate had no effect"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_rejects_non_2d():
|
|
81
|
+
lin = torch.nn.Linear(8, 8, bias=True) # bias is 1-D
|
|
82
|
+
try:
|
|
83
|
+
EchoMuon(lin.parameters(), lr=0.02)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return
|
|
86
|
+
raise AssertionError("non-2D parameter was accepted")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_state_dict_roundtrip():
|
|
90
|
+
m = make_model()
|
|
91
|
+
opt = EchoMuon(m.parameters(), lr=0.02, gate_every=10)
|
|
92
|
+
run(m, opt, n=25)
|
|
93
|
+
sd_model = copy.deepcopy(m.state_dict())
|
|
94
|
+
sd_opt = copy.deepcopy(opt.state_dict())
|
|
95
|
+
# continue 10 more steps -> reference
|
|
96
|
+
ref = copy.deepcopy(m)
|
|
97
|
+
ref_opt = EchoMuon(ref.parameters(), lr=0.02, gate_every=10)
|
|
98
|
+
ref.load_state_dict(sd_model)
|
|
99
|
+
ref_opt.load_state_dict(copy.deepcopy(sd_opt))
|
|
100
|
+
# note: generators differ, so drive both with the SAME fresh data
|
|
101
|
+
g = torch.Generator().manual_seed(99)
|
|
102
|
+
batches = [(torch.randn(16, 32, generator=g), torch.randint(0, 10, (16,), generator=g))
|
|
103
|
+
for _ in range(10)]
|
|
104
|
+
for x, y in batches:
|
|
105
|
+
loss = torch.nn.functional.cross_entropy(m(x), y)
|
|
106
|
+
opt.zero_grad(); loss.backward(); opt.step()
|
|
107
|
+
m2 = ref
|
|
108
|
+
for x, y in batches:
|
|
109
|
+
loss = torch.nn.functional.cross_entropy(m2(x), y)
|
|
110
|
+
ref_opt.zero_grad(); loss.backward(); ref_opt.step()
|
|
111
|
+
assert torch.allclose(flat(m), flat(m2), atol=1e-6), "resume diverged"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_controller():
|
|
115
|
+
m = make_model()
|
|
116
|
+
opt = EchoMuon(m.parameters(), lr=0.02, gate_every=10)
|
|
117
|
+
ctl = MemorizationGapController(opt, probe_every=5, ema=0.0)
|
|
118
|
+
assert not ctl.due(3) and ctl.due(5)
|
|
119
|
+
lam = ctl.update(fresh_loss=2.0, reseen_loss=2.0) # no gap -> 0
|
|
120
|
+
assert lam == 0.0 and opt.gate_lambda == 0.0
|
|
121
|
+
lam = ctl.update(fresh_loss=2.0, reseen_loss=1.0) # huge gap -> clip 1
|
|
122
|
+
assert lam == 1.0 and opt.gate_lambda == 1.0
|
|
123
|
+
lam = ctl.update(fresh_loss=2.0, reseen_loss=2.0 - 0.02) # gap = 0.5*target
|
|
124
|
+
assert abs(lam - 0.5) < 1e-6
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
|
129
|
+
for fn in fns:
|
|
130
|
+
fn()
|
|
131
|
+
print(f" {fn.__name__} ... ok")
|
|
132
|
+
print(f"ALL {len(fns)} TESTS PASS")
|
|
133
|
+
sys.exit(0)
|