echomuon 0.1.0__py3-none-any.whl

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/__init__.py 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__"]
echomuon/optimizer.py ADDED
@@ -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,7 @@
1
+ echomuon/__init__.py,sha256=5TsodsqwFiyrUuKNBVoivw1ahXvkOk3rzzILexuY5G8,361
2
+ echomuon/optimizer.py,sha256=dRVTtQOl3Vv28r16CaqRwKNsrR7p9MKTPQmOiJ-bYjQ,9991
3
+ echomuon-0.1.0.dist-info/licenses/LICENSE,sha256=g8bStZWYbDUnFBDn4xfMJTKECPPKPFnMYduSTpyFiXU,1082
4
+ echomuon-0.1.0.dist-info/METADATA,sha256=3rLGzUmSK8BzzdaQLZmOA9bQBiX-dRNwKqGl02NGUyY,5096
5
+ echomuon-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ echomuon-0.1.0.dist-info/top_level.txt,sha256=f1d36YUQTRbY7mg3oOXgxR5AxMXkE3FuzwUKL9_Bsxc,9
7
+ echomuon-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ echomuon