Download tethergate.py from JakCwi/tethergate: direct link, hf CLI and curl.
- Browser
- Download file 9.28 kB
-
https://huggingface.co/JakCwi/tethergate/resolve/main/tethergate.py
- Command line
-
hf download hf://JakCwi/tethergate/tethergate.py
-
curl -L -o tethergate.py https://huggingface.co/JakCwi/tethergate/resolve/main/tethergate.py
9.28 kB
| # SPDX-License-Identifier: CC-BY-NC-4.0 | |
| # Copyright (c) 2026 Jakub Ćwirlej | |
| # Licensed under Creative Commons Attribution-NonCommercial 4.0 International: free for non-commercial use with attribution to the author. | |
| """ | |
| TetherGate — relative-anchor spring gating that keeps learned gates from collapsing. | |
| A learnable gate scales a residual branch: out = x + g * branch(x). The branch can be an expert (MoE), an adapter | |
| (LoRA, domain adapters), a newly added layer or block, a cross-attention / memory / retrieval path, or any scalar of | |
| the ReZero / LayerScale kind. The gate is driven by a control signal `s` that you choose (any statistic of how much | |
| the branch is needed — or a constant, which turns the gate into an anchored learnable scalar). | |
| Status: experimental — tested in our own training runs, early observations promising; not a validated method. | |
| THE PROBLEM | |
| When the loss does not "pay" for keeping a branch open — the branch hurts at initialization, or its benefit is | |
| delayed — the optimizer slowly pushes the gate to zero, and the branch stops contributing to the model. | |
| Two independent drifts cause it: | |
| 1. early in training, the control signal drifts below a threshold that has not moved yet; | |
| 2. later, the threshold itself drifts upward, because the loss-only equilibrium moves towards "closed". | |
| THE MECHANISM | |
| g_raw = sigmoid( clamp( beta * (s - theta), arg_min, +8 ) ) | |
| g = F(t) + (1 - F(t)) * g_raw # affine floor, scheduled by the training STEP | |
| after every optimizer step (outside autograd): | |
| theta <- theta - k(t) * (theta - theta_star) | |
| theta_star = EMA(s) - logit(g_target) / beta # RELATIVE anchor: follows the signal's own drift | |
| The spring removes the common one-sided drift; the gate's mean openness settles near g_target independently of | |
| the learning rate (the LR only sets the time constant), while the per-token selectivity driven by `s` stays. | |
| Gradients through the gate are real in both directions (forward == backward; the wide clamp keeps a live | |
| gradient even near the floor). Outside the clamp range — beta * (s - theta) below arg_min (default -8) or | |
| above +8 — the gradient to theta (and to s) is exactly 0; theta then moves only through the spring. | |
| PRACTICAL NOTES | |
| * g_target: 0.5 is a safe default; use 0.3–0.7 to bias a branch towards closed / open. | |
| * beta (fixed): 4–8 for a signal of unit scale; normalise `s` (e.g. divide by its running std) if its scale is | |
| very different — beta * std(s) of about 1–3 keeps the gate selective without saturating. | |
| * Floor schedule F(t): keep 1.0 (gate fully open, "asleep") until the branch has warmed up — a few hundred to a | |
| few thousand steps — then let it decay; 'exp' (default) never reaches f_min, 'cos' reaches it at ramp_end. | |
| Never drive the floor by the loss value. | |
| * Spring strength: kappa_sleep while the floor is 1.0 centres theta fast; afterwards k = lr * wd removes only the | |
| common drift. If the signal drifts quickly, schedule k up smoothly — do not jump it. | |
| * Choosing wd: under a steady closing push the optimizer moves theta by about lr per step and the spring pulls it | |
| back by lr * wd * (theta - theta_star), so theta settles about 1/wd away from its anchor, whatever the LR. The | |
| gate's argument then shifts by about beta / wd: keep beta / wd well below 1 (the default wd = 20 with beta = 6 | |
| gives 0.3 and the gate stays near g_target; wd = 0.5 would give 12 and the gate would close to the floor). | |
| wd = 20 is this file's default (as in the demo); our long production run used wd = 0.5, which leaves | |
| the loss much more freedom — with a small wd, watch the mean gate opening. | |
| * Reopening a gate that has closed: use a ramp (raise the floor gradually); a step change on a mature model is a | |
| loss shock. | |
| * anchor='global' (one target for all gates) lets layers / experts differentiate through their own signal; | |
| anchor='local' forces the same openness everywhere. | |
| * Keep theta in its own parameter group without weight decay (as in the demo below). | |
| * Monitor the variance of `s`: the anchor keeps the gate open, it cannot restore a signal that lost its | |
| information. | |
| * torch.compile: the forward pass has no Python branching on tensors; tethergate_step runs outside the graph. | |
| """ | |
| import math | |
| import torch | |
| import torch.nn as nn | |
| class TetherGate(nn.Module): | |
| """Gate for one branch (or one layer). Call `gate = g(s)`, then `out = x + gate * branch(x)`. | |
| s: tensor of any shape (per token / per sample / per layer) or a python float; the gate | |
| broadcasts like `s`. With a constant s the module reduces to an anchored learnable scalar. | |
| detach_signal: True (default) treats `s` as data — the branch cannot learn to manipulate | |
| its own gate signal. Set False only if you deliberately want gradients to flow into `s`. | |
| """ | |
| def __init__(self, beta=6.0, g_target=0.5, theta_init=0.0, arg_min=-8.0, ema_momentum=0.99, | |
| detach_signal=True): | |
| super().__init__() | |
| self.detach_signal = bool(detach_signal) | |
| self.beta = float(beta) # fixed | |
| self.g_target = float(g_target) | |
| self.arg_min = float(arg_min) | |
| self.ema_momentum = float(ema_momentum) | |
| self.theta = nn.Parameter(torch.tensor(float(theta_init))) | |
| self.register_buffer('floor', torch.tensor(1.0)) # F(t); 1.0 = gate "asleep" (full pass) | |
| self.register_buffer('ema_s', torch.tensor(float('nan'))) | |
| def forward(self, s): | |
| s = torch.as_tensor(s, dtype=self.theta.dtype, device=self.theta.device) | |
| if self.detach_signal: | |
| s = s.detach() | |
| if self.training: | |
| with torch.no_grad(): # statistics only — no Python branching (torch.compile-friendly) | |
| m = s.detach().float().mean() | |
| ema = self.ema_momentum * self.ema_s + (1.0 - self.ema_momentum) * m | |
| self.ema_s.copy_(torch.where(torch.isnan(self.ema_s), m, ema)) | |
| g_raw = torch.sigmoid((self.beta * (s - self.theta)).clamp(self.arg_min, 8.0)) | |
| return self.floor + (1.0 - self.floor) * g_raw | |
| def theta_star(self): | |
| return float(self.ema_s) - math.log(self.g_target / (1.0 - self.g_target)) / self.beta | |
| def floor_schedule(step, ramp_start=3000, f_min=0.05, tau=1800.0, mode='exp', ramp_end=4500): | |
| """F(t): 1.0 until ramp_start, then decays to f_min. 'exp' approaches f_min asymptotically | |
| (never reaches it); 'cos' reaches f_min at ramp_end. Driven by the STEP, never by the loss.""" | |
| if step < ramp_start: | |
| return 1.0 | |
| if mode == 'exp': | |
| return f_min + (1.0 - f_min) * math.exp(-(step - ramp_start) / tau) | |
| x = min(1.0, (step - ramp_start) / max(1, ramp_end - ramp_start)) | |
| return f_min + (1.0 - f_min) * 0.5 * (1.0 + math.cos(math.pi * x)) | |
| def tethergate_step(gates, step, lr, wd=20.0, kappa_sleep=5e-3, anchor='global', **floor_kw): | |
| """Call once per training step, AFTER optimizer.step(). | |
| Sets the floor F(t) and pulls every theta towards its relative anchor. | |
| k = kappa_sleep while the floor is 1.0 (theta gets no loss gradient then — center it fast), | |
| afterwards k = lr * wd (slow mode: only the common drift is removed). | |
| anchor='global': one target from the mean EMA over all gates (keeps differentiation); | |
| anchor='local' : one target per gate.""" | |
| f = floor_schedule(step, **floor_kw) | |
| gates = [g for g in gates if not torch.isnan(g.ema_s)] | |
| for g in gates: | |
| g.floor.fill_(f) | |
| if not gates: | |
| return f | |
| k = kappa_sleep if f >= 1.0 else lr * wd | |
| if anchor == 'global': | |
| t_star = sum(g.theta_star() for g in gates) / len(gates) | |
| for g in gates: | |
| g.theta.sub_(k * (g.theta - t_star)) | |
| else: | |
| for g in gates: | |
| g.theta.sub_(k * (g.theta - g.theta_star())) | |
| return f | |
| if __name__ == '__main__': | |
| # Minimal demo: two layers; each new branch is gated. `s` can be any statistic you choose. | |
| torch.manual_seed(0) | |
| d = 16 | |
| blocks = nn.ModuleList(nn.Linear(d, d) for _ in range(2)) | |
| gates = nn.ModuleList(TetherGate(g_target=0.5) for _ in range(2)) | |
| opt = torch.optim.AdamW([{'params': blocks.parameters(), 'weight_decay': 0.1}, | |
| {'params': gates.parameters(), 'weight_decay': 0.0}], lr=1e-3) | |
| for step in range(400): | |
| x = torch.randn(32, d); h = x; opened = [] | |
| for blk, gt in zip(blocks, gates): | |
| u = blk(h) | |
| s = u.abs().mean(-1, keepdim=True) # example signal: branch activity per token | |
| g = gt(s); opened.append(float(g.detach().mean())) | |
| h = h + g * u | |
| loss = (h - x.flip(-1)).pow(2).mean() | |
| opt.zero_grad(); loss.backward(); opt.step() | |
| tethergate_step(gates, step, lr=1e-3, wd=20.0, kappa_sleep=0.05, ramp_start=100, tau=100.0) | |
| if step % 100 == 99: | |
| print(f'step {step+1}: floor {float(gates[0].floor):.3f} | mean gate {[round(o, 3) for o in opened]} ' | |
| f'| theta {[round(g.theta.item(), 3) for g in gates]} | anchor {[round(g.theta_star(), 3) for g in gates]}') | |