tethergate / tethergate.py
JakCwi's picture
Initial release: code, docs, tests (CC BY-NC 4.0)
d7070c6
Raw History Blame Contribute Delete
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))
@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]}')