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]}')