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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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)