File size: 9,278 Bytes
d7070c6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | # 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))
@torch.no_grad()
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]}')
|